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 01/27] 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 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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 4ce32807e1b4faba5947e903b4be041dfbccc648 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 11:12:05 +0800 Subject: [PATCH 07/27] Fix async injection tool-result ordering --- docs/architecture.md | 2 +- docs/cordis-catalog/events.md | 26 +++++------ docs/event-producer-consumer.md | 26 +++++------ .../2026-06-15-turn-enclosure-invariant.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 43 +++++++++++++------ packages/core/agent-loop/src/loop.ts | 6 +++ packages/core/agent-loop/tests/loop.spec.ts | 41 ++++++++++++++---- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 9 ++-- 10 files changed, 104 insertions(+), 55 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b17c3302d8..79f9819ab4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,7 @@ forever: The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). -Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +Context that arrives while tool results are pending—including asynchronous `agent.inject()` notices and post-tool `additionalContext`—lands after the complete result batch so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. ### Failure Boundaries diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4af431f8c0..6a18fbd359 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:139`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:140`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ 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:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -59,7 +59,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:203`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -71,7 +71,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca 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:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -83,7 +83,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already 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:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -95,7 +95,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:225`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:240`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -119,7 +119,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -131,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -143,7 +143,7 @@ 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:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:251`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -155,7 +155,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -167,7 +167,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bdf1c6e320..e08ab61b81 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: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) | -| `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) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:140`](../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:149`](../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:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:203`](../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:213`](../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:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:225`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:240`](../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:181`](../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) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:158`](../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:251`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:261`](../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:271`](../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/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index e55cd0853e..5ffdedba18 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -18,7 +18,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di **Every session event lives inside a turn** — between a `turn/start` and its matching `turn/end`. Concretely: - The loop appends queued `user/message` events **after** `turn/start` (inside the turn), not before it. `turn/end` is therefore owed the moment those messages are recorded, and the existing finalizer guarantees it. -- An `agent.inject()` made while the agent is **running** appends its `context/message` into the already-open turn (unchanged). +- An `agent.inject()` made while the agent is **running** joins the already-open turn. If assistant tool calls are awaiting results, the accepted context waits in arrival order and appends after the complete result batch so its user-role message cannot split provider tool pairing. - An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`. - The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number. - The `dsh-invariants` plugin **enforces** the invariant in dev: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError`. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 91ed9d5049..3eb1d0b8f6 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while assistant tool calls await results stays in a FIFO until the complete result batch is logged, keeping provider tool messages contiguous. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index a2288c65b7..c45cddda84 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,7 +12,7 @@ import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek- import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { isToolPairingBalanced, snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -149,6 +149,8 @@ export class ReactLoopAgent implements Agent { * this set before the lifecycle unregisters the agent or detaches its session. */ private pendingIdleFlushes = new Set>() + /** Open-turn injections waiting for the active assistant tool-call batch to close. */ + private deferredInjections: InboxMessage[] = [] constructor( private loopCtx: Context, @@ -189,12 +191,11 @@ export class ReactLoopAgent implements Agent { } /** - * Accept one public send/steer payload as the exact detached record shared by - * the live notification and inbox. Lossless-JSON materialization reads every - * nested field once; deep freeze prevents an observer from rewriting queued - * work before the loop drains it. + * Accept one public message payload as a detached record. Lossless-JSON + * materialization reads every nested field once; deep freeze prevents later + * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptInboxMessage(content: ContentBlock[], options?: SendOptions): InboxMessage { + private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage { const source = this.resolveSource(options) const accepted = snapshotJsonValue({ content, source }) if (accepted === undefined) { @@ -210,7 +211,7 @@ export class ReactLoopAgent implements Agent { send(content: ContentBlock[], options?: SendOptions): void { this.assertNotDisposed() - const accepted = this.acceptInboxMessage(content, options) + const accepted = this.acceptMessage(content, options) this.#inbox.enqueue(accepted) const info = { source: accepted.source, steering: false } as const agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) @@ -219,7 +220,7 @@ export class ReactLoopAgent implements Agent { steer(content: ContentBlock[], options?: SendOptions): void { this.assertNotDisposed() if (this._status !== 'running') { this.send(content, options); return } - const accepted = this.acceptInboxMessage(content, options) + const accepted = this.acceptMessage(content, options) this.#inbox.steer(accepted) const info = { source: accepted.source, steering: true } as const agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) @@ -227,14 +228,21 @@ export class ReactLoopAgent implements Agent { inject(content: ContentBlock[], options?: SendOptions): void { this.assertNotDisposed() - const source = this.resolveSource(options) if (isTurnOpen(this.session)) { - // A turn is open in the LOG (decided from the log, not agent status — - // status can be `running` with no turn open): the context/message is - // turn-enclosed by that turn, so append it directly. - this.session.append('context/message', { content, source }, { surfaceOp: 'append' }) + const accepted = this.acceptMessage(content, options) + // Provider protocols require every assistant tool-call batch to be + // followed only by its tool results. Queue arbitrary asynchronous context + // until the tail cut is balanced; an existing queue preserves FIFO in the + // narrow window after the last result and before the loop drains it. + if (this.deferredInjections.length > 0 + || !isToolPairingBalanced(this.session.surface.nodes, this.session.events, null)) { + this.deferredInjections.push(accepted) + return + } + this.session.append('context/message', accepted, { surfaceOp: 'append' }) return } + const source = this.resolveSource(options) // No turn open: wrap the injection in a one-shot turn so every event stays // turn-enclosed (the durability/replay boundary is the turn). const turn = lastTurnNumber(this.session) + 1 @@ -272,6 +280,14 @@ export class ReactLoopAgent implements Agent { } } + /** Append deferred open-turn injections after the loop closes a tool-result batch. */ + private drainDeferredInjections(): void { + const pending = this.deferredInjections.splice(0) + for (const accepted of pending) { + this.session.append('context/message', accepted, { surfaceOp: 'append' }) + } + } + cancel(reason?: string): void { // Arm only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { @@ -336,6 +352,7 @@ export class ReactLoopAgent implements Agent { isCancelled: () => this.cancelRequested, cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, + drainDeferredInjections: () => { this.drainDeferredInjections() }, // Pre-step cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, }) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 18eca40cc1..965b2353c2 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -85,6 +85,8 @@ export interface LoopHandle { clearCancel(): void /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void + /** Append context that arrived while an assistant tool-call batch was awaiting its results. */ + readonly drainDeferredInjections: () => void } /** @@ -349,6 +351,10 @@ async function runTurn( break } + // A successful tool step has committed its complete result batch. Context + // accepted while that batch was pending can now join the open turn. + if (stepOutcome.hadToolCalls) handle.drainDeferredInjections() + // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fb686928b1..851a4e63b9 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -383,22 +383,25 @@ describe('agent loop', () => { expect(flat).toContain('') }) - it('inject() while running appends into the open turn (no extra synthetic turn)', async () => { + it('defers inject() during tool execution until after the tool result', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'noticer', {}, 'calling'), textResponse('done'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // A tool that injects mid-execution: at this point the agent is running, so - // inject must append the context/message into the ALREADY-open turn rather - // than wrap it in its own one-shot turn. + let visibleDuringTool = false ctx.tools.register(defineTool({ name: 'noticer', description: 'injects a notice', parameters: {}, async execute() { - agent.inject([{ type: 'text', text: 'mid-turn notice' }], { source: { kind: 'plugin', plugin: 'x' } }) + await Promise.resolve() + const first = { type: 'text' as const, text: 'mid-turn notice' } + agent.inject([first], { source: { kind: 'plugin', plugin: 'x' } }) + first.text = 'mutated after inject' + agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } }) + visibleDuringTool = agent.session.events.some(e => e.type === 'context/message') return [{ type: 'text', text: 'ok' }] }, })) @@ -406,13 +409,35 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // Exactly ONE turn ran (no synthetic injection turn), and the mid-turn - // context/message sits inside it. + expect(visibleDuringTool).toBe(false) + + // The injection stays in the open turn, but its user-role context cannot + // split the assistant tool call from the provider's tool-result message. const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) const ts0 = turnStarts[0]! expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message') - expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true) + const result = agent.session.events.find(e => e.type === 'tool/result')! + const contexts = agent.session.events.filter(e => e.type === 'context/message') + expect(contexts).toHaveLength(2) + expect(result.seq).toBeLessThan(contexts[0]!.seq) + expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : [])) + .toEqual([ + { type: 'text', text: 'mid-turn notice' }, + { type: 'text', text: 'second notice' }, + ]) + + const secondRequest = adapter.requests[1]!.messages + const resultIndex = secondRequest.findIndex(message => + message.content.some(block => block.type === 'tool-result')) + const contextIndexes = secondRequest.flatMap((message, index) => + message.content.some(block => block.type === 'text' + && (block.text.includes('mid-turn notice') || block.text.includes('second notice'))) + ? [index] + : []) + expect(resultIndex).toBeGreaterThanOrEqual(0) + expect(contextIndexes).toHaveLength(2) + expect(contextIndexes.every(index => index > resultIndex)).toBe(true) }) it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..ed42741cc1 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -41,7 +41,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle -- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) +- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. While a turn is open it joins that turn, deferring FIFO behind any pending tool-result batch so provider pairing remains contiguous; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..efe66693bf 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -103,10 +103,11 @@ export interface Agent { steer(content: ContentBlock[], options?: SendOptions): void /** - * 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. + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless tool results are pending, + * in which case it waits FIFO until the complete result batch is logged. Idle + * injection uses a one-shot turn and durability checkpoint. Disposal awaits + * idle checkpoints; flush failures report through `agent/error`. */ inject(content: ContentBlock[], options?: SendOptions): void From 28144077f3da1a43cbb31639ef4a488ec5cca68e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:22:39 +0800 Subject: [PATCH 08/27] Fix deferred injection lifecycle --- docs/architecture.md | 2 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 25 ++-- packages/core/agent-loop/src/loop.ts | 98 ++++++++-------- .../tests/contract-regressions.spec.ts | 109 ++++++++++++++++++ packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 8 +- 8 files changed, 184 insertions(+), 64 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 79f9819ab4..1b850da799 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,7 @@ forever: The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). -Context that arrives while tool results are pending—including asynchronous `agent.inject()` notices and post-tool `additionalContext`—lands after the complete result batch so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +Context that arrives while the current tool-call batch executes—including asynchronous `agent.inject()` notices and post-tool `additionalContext`—waits until execution settles and lands after every recorded result; successful batches keep call/result adjacency stable, while interrupted batches drain accepted context before the turn closes. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. ### Failure Boundaries diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 5ffdedba18..a54f735219 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -18,7 +18,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di **Every session event lives inside a turn** — between a `turn/start` and its matching `turn/end`. Concretely: - The loop appends queued `user/message` events **after** `turn/start` (inside the turn), not before it. `turn/end` is therefore owed the moment those messages are recorded, and the existing finalizer guarantees it. -- An `agent.inject()` made while the agent is **running** joins the already-open turn. If assistant tool calls are awaiting results, the accepted context waits in arrival order and appends after the complete result batch so its user-role message cannot split provider tool pairing. +- An `agent.inject()` made while the agent is **running** joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted. - An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`. - The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number. - The `dsh-invariants` plugin **enforces** the invariant in dev: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError`. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 3eb1d0b8f6..1ee596c285 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while assistant tool calls await results stays in a FIFO until the complete result batch is logged, keeping provider tool messages contiguous. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index c45cddda84..24852aa9b7 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,7 +12,7 @@ import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek- import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { isToolPairingBalanced, snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -149,6 +149,8 @@ export class ReactLoopAgent implements Agent { * this set before the lifecycle unregisters the agent or detaches its session. */ private pendingIdleFlushes = new Set>() + /** Whether the current step is executing an assistant tool-call batch. */ + private toolBatchActive = false /** Open-turn injections waiting for the active assistant tool-call batch to close. */ private deferredInjections: InboxMessage[] = [] @@ -231,11 +233,9 @@ export class ReactLoopAgent implements Agent { if (isTurnOpen(this.session)) { const accepted = this.acceptMessage(content, options) // Provider protocols require every assistant tool-call batch to be - // followed only by its tool results. Queue arbitrary asynchronous context - // until the tail cut is balanced; an existing queue preserves FIFO in the - // narrow window after the last result and before the loop drains it. - if (this.deferredInjections.length > 0 - || !isToolPairingBalanced(this.session.surface.nodes, this.session.events, null)) { + // followed only by its tool results. Historical interrupted batches do + // not own new context; only the currently executing batch may defer it. + if (this.toolBatchActive) { this.deferredInjections.push(accepted) return } @@ -288,6 +288,17 @@ export class ReactLoopAgent implements Agent { } } + /** Run one tool-call batch and drain its deferred context before resolving or rejecting. */ + private async withToolBatch(run: () => Promise): Promise { + this.toolBatchActive = true + try { + return await run() + } finally { + this.toolBatchActive = false + this.drainDeferredInjections() + } + } + cancel(reason?: string): void { // Arm only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { @@ -352,7 +363,7 @@ export class ReactLoopAgent implements Agent { isCancelled: () => this.cancelRequested, cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, - drainDeferredInjections: () => { this.drainDeferredInjections() }, + withToolBatch: run => this.withToolBatch(run), // Pre-step cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, }) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 965b2353c2..4a6eea8a99 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -85,8 +85,8 @@ export interface LoopHandle { clearCancel(): void /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void - /** Append context that arrived while an assistant tool-call batch was awaiting its results. */ - readonly drainDeferredInjections: () => void + /** Run an active tool-call batch and drain deferred context before resolving or rejecting. */ + readonly withToolBatch: (run: () => Promise) => Promise } /** @@ -327,7 +327,7 @@ async function runTurn( let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { stepOutcome = await runStep( - ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) + ctx, events, agent, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { @@ -351,10 +351,6 @@ async function runTurn( break } - // A successful tool step has committed its complete result batch. Context - // accepted while that batch was pending can now join the open turn. - if (stepOutcome.hadToolCalls) handle.drainDeferredInjections() - // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason @@ -468,6 +464,7 @@ async function runStep( ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, + handle: LoopHandle, turn: number, step: number, assembly: PromptAssembly, @@ -561,51 +558,54 @@ async function runStep( // Tool execution stays sequential; recheck abort around each normalized result. const toolCalls = message.content.filter(block => block.type === 'tool-call') - // 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 */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) - let parsedArguments: unknown - try { - parsedArguments = call.arguments ? JSON.parse(call.arguments) : {} - } catch { - parsedArguments = call.arguments + if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish } + return handle.withToolBatch(async () => { + // 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 */ + if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) + let parsedArguments: unknown + try { + parsedArguments = call.arguments ? JSON.parse(call.arguments) : {} + } catch { + parsedArguments = call.arguments + } + // 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, + arguments: parsedArguments, + agent, + signal, + }) + session.append('tool/result', { + turn, step, + // Preserve transcript pairing even if a post-execute listener returns another id. + callId: call.id, + content: result.content, + isError: result.isError, + ...result.error ? { error: result.error } : {}, + // Persist tool-owned presentation data for replay. + ...result.meta !== undefined ? { meta: result.meta } : {}, + }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) + if (result.additionalContext) pendingContext.push(result.additionalContext) + // 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 */ } - // 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, - arguments: parsedArguments, - agent, - signal, - }) - session.append('tool/result', { - turn, step, - // Preserve transcript pairing even if a post-execute listener returns another id. - callId: call.id, - content: result.content, - isError: result.isError, - ...result.error ? { error: result.error } : {}, - // Persist tool-owned presentation data for replay. - ...result.meta !== undefined ? { meta: result.meta } : {}, - }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - if (result.additionalContext) pendingContext.push(result.additionalContext) - // 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 context after the complete result batch. - for (const context of pendingContext) { - agent.inject(context.content, { source: context.source }) - } + // Append buffered context after the complete result batch. + for (const context of pendingContext) { + agent.inject(context.content, { source: context.source }) + } - return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish } + return { hadToolCalls: true, finish: assembler.finish } + }) } function withoutToolCalls(message: Message): Message { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 8130d4b893..1d951c16c2 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -139,6 +139,115 @@ describe('abort during tool execution ends the turn', () => { expect(adapter.requests).toHaveLength(1) // no follow-up model call expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) }) + + it('records context accepted before a tool-step abort in the same turn', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-abort-injection'), { model: 'mock' }) + ctx.tools.register(defineTool({ + name: 'aborter', + description: '', + parameters: {}, + async execute() { + agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } }) + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + return [{ type: 'text', text: 'done' }] + }, + })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + expect(events + .filter(event => event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'step/end' || event.type === 'turn/end') + .map(event => event.type)) + .toEqual(['tool/result', 'context/message', 'step/end', 'turn/end']) + expect(events.find(event => event.type === 'context/message')?.data.content) + .toEqual([{ type: 'text', text: 'accepted before abort' }]) + }) + + it('drains deferred context before disposal reaches quiescence', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})]) + const ctx = await harness(adapter) + const started = Promise.withResolvers() + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-dispose-injection'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + ctx.tools.register(defineTool({ + name: 'waiter', + description: '', + parameters: {}, + async execute(_args, exec) { + agent.inject([{ type: 'text', text: 'accepted before disposal' }], { source: { kind: 'plugin', plugin: 'test' } }) + started.resolve(undefined) + const signal = exec.signal + if (!signal) throw new Error('tool execution signal is missing') + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return [{ type: 'text', text: 'done' }] + }, + })) + + send(agent, 'go') + await started.promise + await fiber.dispose() + + expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) + .toEqual([{ type: 'text', text: 'accepted before disposal' }]) + expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) + .toEqual({ kind: 'disposed' }) + }) + + it('limits injection deferral to the current tool batch', async () => { + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] satisfies StreamChunk[], + textResponse('later turn'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-historical-tool-pair'), { model: 'mock' }) + ctx.tools.register(defineTool({ + name: 'aborter', + description: '', + parameters: {}, + async execute() { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + return [{ type: 'text', text: 'done' }] + }, + })) + ctx.tools.register(defineTool({ + name: 'second', + description: '', + parameters: {}, + async execute() { + return [{ type: 'text', text: 'must not run' }] + }, + })) + + send(agent, 'leave an unmatched historical call') + await waitForIdle(ctx, agent) + ctx.on('agent/pre-step', (subject, turn) => { + if (subject === agent && turn === 2) { + agent.inject([{ type: 'text', text: 'new turn context' }], { source: { kind: 'plugin', plugin: 'test' } }) + } + }) + send(agent, 'start a text-only turn') + await waitForIdle(ctx, agent) + + expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) + .toEqual([{ type: 'text', text: 'new turn context' }]) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context') + }) }) describe('steering from late extension points is never stranded', () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index ed42741cc1..03b0957fbf 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -41,7 +41,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle -- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. While a turn is open it joins that turn, deferring FIFO behind any pending tool-result batch so provider pairing remains contiguous; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). +- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index efe66693bf..f1a704125a 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -104,10 +104,10 @@ export interface Agent { /** * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless tool results are pending, - * in which case it waits FIFO until the complete result batch is logged. Idle - * injection uses a one-shot turn and durability checkpoint. Disposal awaits - * idle checkpoints; flush failures report through `agent/error`. + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before turn + * close even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. */ inject(content: ContentBlock[], options?: SendOptions): void From c3d5568efd6a626ae3a3a585495d3113f35aac99 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:31:25 +0800 Subject: [PATCH 09/27] Document deferred injection ordering --- docs/core-data-structures/core.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9a9a1706fc..7adda313a4 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -281,9 +281,14 @@ interface Agent { /** * 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. + * notifications, …): accepts context for a `context/message` session event + * the next model request sees, rendered as tagged synthetic context rather + * than a user prompt. Does not run the model. + * + * In an open turn, inject appends at the current log position except while + * the current tool-call batch executes: accepted context waits FIFO until the + * batch settles, then appends after every recorded result and before turn + * close even when execution is interrupted. * * 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` From edfc4dc4ac8c9a5ae8c45cd2b87ef3e629685920 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:49:50 +0800 Subject: [PATCH 10/27] Preserve interrupted post-tool context --- docs/core-data-structures/tools.md | 9 +- .../feature/2026-06-30-interception-seams.md | 2 +- docs/tool-execution-pipeline.md | 4 +- packages/core/agent-loop/src/agent.ts | 18 +++- packages/core/agent-loop/src/loop.ts | 17 ++-- .../tests/contract-regressions.spec.ts | 85 +++++++++++++++++-- packages/core/tools/src/index.ts | 6 +- scripts/gen-doc-graphs.ts | 4 +- 8 files changed, 113 insertions(+), 32 deletions(-) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 08c289e5e5..1aa20d5383 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -151,10 +151,11 @@ interface ToolExecutionResult { * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part * of this call's `content` — `content`/`feedback` shape the tool RESULT, but * `additionalContext` is a SEPARATE `context/message`. A step can carry - * multiple tool calls, so the loop BUFFERS every call's `additionalContext` - * and appends them only AFTER all `tool/result`s for the step, keeping - * tool-call/result adjacency intact. Carried on the result purely to ferry it - * from `execute()` up to the loop's per-step buffer. + * multiple tool calls, so the loop accepts every call's `additionalContext` + * into the active-batch FIFO and appends it only when that batch settles. A + * successful batch places context AFTER all its `tool/result`s; an interrupted + * batch places it after every recorded result and before turn close. Carried + * on the result purely to ferry it from `execute()` up to that FIFO. */ additionalContext?: HookContext /** diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ea371ad55a..303c78055d 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -36,7 +36,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li 1. **Open the turn before prompt policy.** A fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Allowed `additionalContext` is injected into the open turn. -2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. +2. **Post-tool `additionalContext` enters the active-batch FIFO and appends when that batch settles.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`; the loop accepts each context into the same FIFO as asynchronous injections, then appends the FIFO after the complete result batch on success or after every recorded result before an interrupted turn closes. 3. **A forced `continue` `reason` is enqueued through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt (matching the existing `hasSteering` force-continue override). diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 51641d4655..0caf979efd 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -20,9 +20,9 @@ flowchart TD owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] final["tools/result synchronous notification
frozen authoritative outcome"] - context["Buffered additionalContext
context/message after all tool results"] + context["Batch-deferred additionalContext
context/message after recorded tool results"] toolResult["Session event: tool/result
single model-facing outcome"] - allResults["All calls in the step settled
and tool/result events recorded"] + allResults["Tool batch settled
recorded tool/result events complete"] presentResult["UI completed card
presentResult(args, result)"] model --> toolCall toolCall --> presentCall diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 24852aa9b7..82d381bcae 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentId, AgentOptions, AgentStatus, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' @@ -288,11 +288,21 @@ export class ReactLoopAgent implements Agent { } } - /** Run one tool-call batch and drain its deferred context before resolving or rejecting. */ - private async withToolBatch(run: () => Promise): Promise { + /** + * Run one tool-call batch and drain its deferred context before settlement. + * The loop-owned acceptor remains valid after public disposal begins because + * the interrupted turn stays open until this batch settles. + */ + private async withToolBatch( + run: (acceptContext: (context: HookContext) => void) => Promise, + ): Promise { this.toolBatchActive = true + const acceptContext = (context: HookContext): void => { + const accepted = this.acceptMessage(context.content, { source: context.source }) + this.deferredInjections.push(accepted) + } try { - return await run() + return await run(acceptContext) } finally { this.toolBatchActive = false this.drainDeferredInjections() diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 4a6eea8a99..0c6ba7a4fa 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -85,8 +85,8 @@ export interface LoopHandle { clearCancel(): void /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void - /** Run an active tool-call batch and drain deferred context before resolving or rejecting. */ - readonly withToolBatch: (run: () => Promise) => Promise + /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ + readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise } /** @@ -559,9 +559,7 @@ async function runStep( // Tool execution stays sequential; recheck abort around each normalized result. const toolCalls = message.content.filter(block => block.type === 'tool-call') if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish } - return handle.withToolBatch(async () => { - // Buffer context until all results are appended to preserve call/result adjacency. - const pendingContext: HookContext[] = [] + return handle.withToolBatch(async (acceptContext) => { for (const call of toolCalls) { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) @@ -591,7 +589,9 @@ async function runStep( // Persist tool-owned presentation data for replay. ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - if (result.additionalContext) pendingContext.push(result.additionalContext) + // Accept into the batch FIFO immediately; it remains deferred until every + // result settles and survives abort, cancellation, or disposal afterward. + if (result.additionalContext) acceptContext(result.additionalContext) // 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 @@ -599,11 +599,6 @@ async function runStep( /* v8 ignore stop */ } - // Append buffered context after the complete result batch. - for (const context of pendingContext) { - agent.inject(context.content, { source: context.source }) - } - return { hadToolCalls: true, finish: assembler.finish } }) } diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 1d951c16c2..cc75943c4d 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' @@ -154,6 +154,13 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) + ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'accept', + additionalContext: { + content: [{ type: 'text', text: 'accepted result context after abort' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + })) send(agent, 'go') await waitForIdle(ctx, agent) @@ -163,9 +170,65 @@ describe('abort during tool execution ends the turn', () => { .filter(event => event.type === 'tool/result' || event.type === 'context/message' || event.type === 'step/end' || event.type === 'turn/end') .map(event => event.type)) - .toEqual(['tool/result', 'context/message', 'step/end', 'turn/end']) + .toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end']) + expect(events + .filter(event => event.type === 'context/message') + .map(event => event.data.content)) + .toEqual([ + [{ type: 'text', text: 'accepted before abort' }], + [{ type: 'text', text: 'accepted result context after abort' }], + ]) + }) + + it('records post-tool context when a later call aborts the batch', async () => { + const adapter = new MockAdapter([[ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'first', arguments: '{}' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'aborter', arguments: '{}' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] satisfies StreamChunk[]]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-later-abort-context'), { model: 'mock' }) + ctx.tools.register(defineTool({ + name: 'first', + description: '', + parameters: {}, + async execute() { + return [{ type: 'text', text: 'first done' }] + }, + })) + ctx.tools.register(defineTool({ + name: 'aborter', + description: '', + parameters: {}, + async execute() { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + return [{ type: 'text', text: 'aborted' }] + }, + })) + ctx.on('tools/post-execute', async (exec, _result, next): Promise => { + if (exec.callId !== CallId('c1')) return next() + return { + kind: 'accept', + additionalContext: { + content: [{ type: 'text', text: 'accepted after first result' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + } + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + expect(events + .filter(event => event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'step/end' || event.type === 'turn/end') + .map(event => event.type)) + .toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end']) expect(events.find(event => event.type === 'context/message')?.data.content) - .toEqual([{ type: 'text', text: 'accepted before abort' }]) + .toEqual([{ type: 'text', text: 'accepted after first result' }]) }) it('drains deferred context before disposal reaches quiescence', async () => { @@ -192,13 +255,25 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) + ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'accept', + additionalContext: { + content: [{ type: 'text', text: 'accepted result context during disposal' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + })) send(agent, 'go') await started.promise await fiber.dispose() - expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) - .toEqual([{ type: 'text', text: 'accepted before disposal' }]) + expect(agent.session.events + .filter(event => event.type === 'context/message') + .map(event => event.data.content)) + .toEqual([ + [{ type: 'text', text: 'accepted before disposal' }], + [{ type: 'text', text: 'accepted result context during disposal' }], + ]) expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'disposed' }) }) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 988c6a0268..695a1e2ff1 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -238,8 +238,8 @@ export interface ToolExecutionResult { */ error?: ToolErrorInfo /** - * Model-facing context for the next request, separate from this tool result. - * The loop buffers it until all step results are logged, preserving pairing. + * Model-facing context for the next request, separate from this tool result. The loop + * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. */ additionalContext?: HookContext /** @@ -843,7 +843,7 @@ export class ToolRegistry extends Service { * its {@link PostToolDecision}: `accept` keeps the call successful (replacing * `content` when given), `block` turns it into an `isError` whose content is * the corrective `feedback`. Either decision may attach `additionalContext`, - * which is ferried on the returned result for the loop's per-step buffer. + * which is ferried on the returned result for the loop's active-batch FIFO. * Runs inside `execute`'s outer try/catch (a throwing listener → isError). */ private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a6660f7cd3..a836b399e6 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -850,9 +850,9 @@ function renderToolPipeline(): string { ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, ` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`, - ' context["Buffered additionalContext
context/message after all tool results"]', + ' context["Batch-deferred additionalContext
context/message after recorded tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, - ' allResults["All calls in the step settled
and tool/result events recorded"]', + ' allResults["Tool batch settled
recorded tool/result events complete"]', ' presentResult["UI completed card
presentResult(args, result)"]', ' model --> toolCall', ' toolCall --> presentCall', From f038780ff65691efcce782461848c1b6fdfa67aa Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 14:47:29 +0800 Subject: [PATCH 11/27] feat(llm): add replay token metering (PR2 round 1) --- docs/agent-lifecycle.md | 2 + docs/architecture.md | 3 +- docs/capability-seams.md | 5 + docs/config-catalog.md | 71 +- docs/cordis-catalog/services.md | 14 +- docs/core-data-structures/compaction.md | 2 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/session.md | 4 + docs/core-data-structures/token-meter.md | 52 + docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 7 +- docs/rfc/INDEX.md | 1 + .../2026-06-18-session-surface.md | 6 +- ...07-15-replay-token-meter-service.i18n.yaml | 6 + .../2026-07-15-replay-token-meter-service.md | 57 + ...026-07-15-replay-token-meter-service.zh.md | 57 + .../2026-06-18-compaction-capability-seam.md | 18 +- examples/coding-agent/composition.md | 3 + examples/coding-agent/cordis.yml | 15 +- examples/coding-agent/tests/compaction.e2e.ts | 11 +- examples/coding-agent/tests/harness.ts | 13 +- packages/compact/README.md | 4 +- packages/compact/compact-basic/README.md | 37 +- packages/compact/compact-basic/package.json | 9 +- .../compact/compact-basic/src/automatic.ts | 60 + packages/compact/compact-basic/src/config.ts | 117 + packages/compact/compact-basic/src/index.ts | 624 +---- packages/compact/compact-basic/src/region.ts | 196 ++ .../compact/compact-basic/src/summarizer.ts | 153 ++ packages/compact/compact-basic/src/types.ts | 114 +- .../compact-basic/tests/compact-basic.spec.ts | 2427 ++++++----------- .../tests/compact-loop-repro.spec.ts | 14 +- .../tests/loader-composition.spec.ts | 66 + packages/compact/compact-basic/tsconfig.json | 2 + packages/compact/compact/README.md | 6 +- packages/compact/compact/src/index.ts | 9 +- .../cordis/tool-cordis/src/api-catalog.ts | 35 + packages/core/agent-loop/README.md | 2 + packages/core/agent-loop/src/loop.ts | 33 +- .../tests/contract-regressions.spec.ts | 9 +- packages/core/agent-loop/tests/loop.spec.ts | 30 +- packages/core/session/README.md | 2 +- packages/core/session/src/types.ts | 10 +- packages/llm/README.md | 3 +- packages/llm/token-meter/README.md | 57 + packages/llm/token-meter/package.json | 37 + packages/llm/token-meter/src/index.ts | 193 ++ packages/llm/token-meter/src/replay.ts | 367 +++ packages/llm/token-meter/src/types.ts | 101 + .../llm/token-meter/tests/token-meter.spec.ts | 603 ++++ packages/llm/token-meter/tsconfig.json | 27 + packages/support/invariants/README.md | 1 + packages/support/invariants/src/index.ts | 4 +- .../invariants/tests/invariants.spec.ts | 8 +- pnpm-lock.yaml | 34 +- python/sdk-runtime/package.json | 1 + scripts/gen-doc-graphs.ts | 10 + scripts/type-equiv.manifest.json | 4 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 61 files changed, 3393 insertions(+), 2369 deletions(-) create mode 100644 docs/core-data-structures/token-meter.md create mode 100644 docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md create mode 100644 docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md create mode 100644 packages/compact/compact-basic/src/automatic.ts create mode 100644 packages/compact/compact-basic/src/config.ts create mode 100644 packages/compact/compact-basic/src/region.ts create mode 100644 packages/compact/compact-basic/src/summarizer.ts create mode 100644 packages/compact/compact-basic/tests/loader-composition.spec.ts create mode 100644 packages/llm/token-meter/README.md create mode 100644 packages/llm/token-meter/package.json create mode 100644 packages/llm/token-meter/src/index.ts create mode 100644 packages/llm/token-meter/src/replay.ts create mode 100644 packages/llm/token-meter/src/types.ts create mode 100644 packages/llm/token-meter/tests/token-meter.spec.ts create mode 100644 packages/llm/token-meter/tsconfig.json diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index fa2c4bd0fb..b9292aa80e 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -45,6 +45,8 @@ sequenceDiagram Driver-->>SDK: agent/status idle ``` +The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set. + SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. Maintenance mode: curated Mermaid sequence; exact event signatures live in the generated Cordis catalog. diff --git a/docs/architecture.md b/docs/architecture.md index b17c3302d8..75a03c474c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | ctx key | Package family | Role | |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware per-model request and surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | @@ -124,7 +125,7 @@ Durability is a plugin concern. Persistence backends buffer synchronous `session ### Model Content -Messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`). The union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types are coordinated across adapters, UI bridges, compaction pricing, and persistence, so block types remain a repo-wide contract. +Messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`). The union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types are coordinated across adapters, UI bridges, token metering, and persistence, so block types remain a repo-wide contract. Replay measurement types are cataloged in [token-meter.md](core-data-structures/token-meter.md). Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAssembler` as the shared chunk-to-block assembler. The loop logs raw chunks while assembling them for dispatch. `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register with `ctx.llm.registerAdapter(models, adapter)`. StreamChunk conventions live in [llm-streaming.md](core-data-structures/llm-streaming.md). diff --git a/docs/capability-seams.md b/docs/capability-seams.md index fb4746dc68..fae89bfcfb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -14,6 +14,8 @@ flowchart LR pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compact_basic["compact-basic"] + pkg_token_meter["token-meter"] + svc_tokenMeter["ctx.tokenMeter
Replay token measurement"] pkg_session["session"] svc_sessions["ctx.sessions
In-memory session store"] pkg_agent["agent"] @@ -120,6 +122,7 @@ flowchart LR pkg_subagent_mock --> svc_subagents pkg_subagent_spawn --> svc_subagents pkg_system_prompt --> svc_systemPrompt + pkg_token_meter --> svc_tokenMeter pkg_tools --> svc_tools pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -162,6 +165,7 @@ flowchart LR svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_web svc_systemPrompt --> pkg_tools + svc_tokenMeter --> pkg_compact_basic svc_tools --> pkg_acp svc_tools --> pkg_agent_loop svc_tools --> pkg_tool_ask_user @@ -183,6 +187,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | +| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-model/session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8139f4d943..4bbab27e16 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -207,44 +207,33 @@ Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:24`](../package ## `@deepseek-ai/dsh-compact-basic` -Requires: `llm` +Requires: `llm` · `tokenMeter` ```ts config-catalog -/** - * Backend configuration. Every knob is REQUIRED except `auto` and - * `charsPerToken`: there is no concrete data yet to justify default - * thresholds/budgets, so a consumer must state each value explicitly rather - * than inherit a guessed default. `auto` alone defaults to `true` - * (auto-compaction is the intended posture), and `charsPerToken` defaults to - * the English-text heuristic its estimator was calibrated on. - */ +/** Basic compaction configuration; every common field has a deployment default. */ export interface BasicCompactConfig { - /** Context window size in tokens. */ - contextWindow: number - /** Compact when estimated token usage exceeds this fraction of context window. */ - thresholdRatio: number - /** Number of tokens of recent context to retain during compaction. */ - retainTokens: number - /** Model to use for summarization (`''` — uses the agent's model). */ - summarizationModel: string - /** Provider generation cap for the summarization call. */ - maxTokens: number - /** Extra compaction attempts when the first compacted surface is still over threshold. */ - compactionRetries: number - /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ + /** Field-wise pressure/retention overrides keyed by configured token-meter model name. */ + models?: Record + /** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */ + summarizationModel?: string + /** Provider generation cap for summarization. Defaults to `8192`. */ + maxTokens?: number + /** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */ + compactionRetries?: number + /** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */ auto?: boolean - /** - * Text density for the token estimator: estimated tokens = chars / - * `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy - * deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so - * the default UNDERestimates several-fold and compaction fires far too late. - * May be fractional. - */ - charsPerToken?: number +} + +/** Optional pressure and retention policy for one metered model. */ +export interface ModelCompactConfig { + /** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */ + thresholdRatio?: number + /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + retainTokens?: number } ``` -Source: [`packages/compact/compact-basic/src/types.ts:20`](../packages/compact/compact-basic/src/types.ts) +Source: [`packages/compact/compact-basic/src/types.ts:16`](../packages/compact/compact-basic/src/types.ts) ## `@deepseek-ai/dsh-fs-local` @@ -796,6 +785,26 @@ export interface Config { Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/time-context/src/index.ts) +## `@deepseek-ai/dsh-token-meter` + +```ts config-catalog +/** Token-meter plugin configuration. */ +export interface TokenMeterConfig { + /** Built-in field overrides and custom model profiles, keyed by routed model name. */ + models?: Record +} + +/** Optional pricing fields for one configured model. */ +export interface ModelTokenMeterConfig { + /** Provider context-window capacity in tokens. Required for a custom model. */ + contextWindow?: number + /** Heuristic text density in characters per token. Defaults to `4`. */ + charsPerToken?: number +} +``` + +Source: [`packages/llm/token-meter/src/types.ts:19`](../packages/llm/token-meter/src/types.ts) + ## `@deepseek-ai/dsh-tool-cordis` Requires: `tools` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3339381e0e..ae808e7fe2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -86,7 +86,7 @@ Source: [`packages/code-runtime/code-runtime/src/index.ts:31`](../../packages/co ## `ctx.compact` — `CompactService` (abstract seam) -Abstract compaction service. Implementations own token estimation, retention, and summarization, but a successful run must replace the selected surface span with one summary node and prevent concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. +Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise @@ -95,7 +95,7 @@ abstract compactRegion( session: Session, start: number, end: number, agent: Com Types: [Message](../core-data-structures/core.md) -Source: [`packages/compact/compact/src/index.ts:37`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:38`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) @@ -241,6 +241,16 @@ async assemble(context: AssembleContext = {}): Promise Source: [`packages/core/system-prompt/src/index.ts:213`](../../packages/core/system-prompt/src/index.ts) +## `ctx.tokenMeter` — `TokenMeterService` + +Concrete registry and replay owner for all configured model meters. + +```ts cordis-catalog +resolve(model: string): ModelTokenMeter +``` + +Source: [`packages/llm/token-meter/src/index.ts:145`](../../packages/llm/token-meter/src/index.ts) + ## `ctx.tools` — `ToolRegistry` Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 05022f9937..50a80208a9 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,7 +50,7 @@ interface CompactionResult { ## The service -`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy. +`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. The seam owns no pricing API: `dsh-compact-basic` resolves the routed model through [`ctx.tokenMeter`](token-meter.md), whose model-bound handle owns estimation and replay, while the backend owns retention, event sequencing, and summarization. Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9a9a1706fc..09805063ac 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -16,6 +16,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | Sub-page | Owns | |---|---| | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | +| [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 174f06602e..d7e503677f 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -150,6 +150,8 @@ type SessionEvent = { `SessionEventType = keyof SessionEventMap`. Because `SessionEventMap` is merge-extensible, switches over `SessionEvent` must NOT use `assertNever` — a plugin-added variant is a valid unknown value; handle the known cases and fall through `default`. +For `assistant/message`, a present `sourceEventSeqs: []` is a complete known-empty provider stream, while an absent field means legacy or otherwise unrecorded provenance. The loop writes the field for every successful model call; every other surface event requires a non-empty list when the field is present. + ## 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). @@ -186,6 +188,8 @@ 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. +The same provenance distinction applies here: only `assistant/message` may carry a present empty `sourceEventSeqs`; omission does not assert that its source stream was empty. + ### `SurfaceNode` — a node in the surface linked list ```ts type-equiv diff --git a/docs/core-data-structures/token-meter.md b/docs/core-data-structures/token-meter.md new file mode 100644 index 0000000000..98467f3715 --- /dev/null +++ b/docs/core-data-structures/token-meter.md @@ -0,0 +1,52 @@ +# Token Meter + +`@deepseek-ai/dsh-token-meter` exposes detached replay measurements for request pressure and positional surface pricing. Scalar and surface snapshots carry the number of durable events consumed as `logRevision`; consumers compare revisions before making a joint decision. + +Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts) + +## `TokenMeasurement` + +```ts type-equiv +interface TokenMeasurement { + /** Model profile used for every heuristic component. */ + readonly model: string + /** Number of durable events consumed; equal to the next unread event seq. */ + readonly logRevision: number + /** Provider or heuristic anchor used for this measurement. */ + readonly baseline: TokenMeasurementBaseline + /** Signed repricing of current surface content relative to the baseline anchor. */ + readonly surfaceDeltaTokens: number + /** Non-negative current request-and-response pressure. */ + readonly totalTokens: number +} +``` + +`baseline.kind === 'usage'` means a successful provider call has the same model and canonical envelope. `estimated` means the meter repriced the complete envelope and surface. Signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching provider or estimated anchor. + +## `TokenSurfaceNode` + +```ts type-equiv +interface TokenSurfaceNode { + /** Durable sequence number of the surface event. */ + readonly seq: number + /** Heuristic tokens for the exact message projected by this node. */ + readonly tokens: number +} +``` + +## `TokenSurfaceMeasurement` + +```ts type-equiv +interface TokenSurfaceMeasurement { + /** Model profile used to price every node. */ + readonly model: string + /** Number of durable events consumed; equal to the next unread event seq. */ + readonly logRevision: number + /** Total heuristic tokens across the current surface. */ + readonly totalTokens: number + /** Current surface nodes in positional head-to-tail order. */ + readonly nodes: readonly TokenSurfaceNode[] +} +``` + +Surface order is authoritative; replacement nodes can have higher durable seqs than later positional nodes. The snapshot is immutable and does not grow when the underlying replay fold advances. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2de200fbba..b4f32dbbae 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent), [`token-meter`](../packages/llm/token-meter) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 19d471f175..64319b7813 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -15,6 +15,7 @@ flowchart TD pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] pkg_llm_pi_ai["llm-pi-ai"] + pkg_token_meter["token-meter"] end subgraph group_core["packages/core"] pkg_agent["agent"] @@ -135,6 +136,8 @@ flowchart TD pkg_fs --> pkg_llm pkg_web --> pkg_llm pkg_sandbox --> pkg_llm + pkg_token_meter --> pkg_llm + pkg_token_meter --> pkg_session pkg_agent --> pkg_brand pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -165,6 +168,7 @@ flowchart TD pkg_compact_basic --> pkg_compact pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session @@ -358,6 +362,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -372,7 +377,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index e37f975827..f62e5e4d14 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -144,6 +144,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | | [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 | | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 | +| [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 | ### Process 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 6ba0df41c4..3a8805ddb6 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -14,7 +14,7 @@ Add a **surface** — a derived, cached linked list of "surface nodes" (the subs Every `SessionEvent` gains two optional fields (structural metadata, like `seq`/`time`): -- **`sourceEventSeqs?: number[]`** — seq numbers of events that are provenance sources (e.g., the `assistant/chunk` seqs that built an `assistant/message`, or the surface nodes shadowed by a compaction marker). Provenance is a core design principle; without it, the replace-range operation cannot be validated on replay. +- **`sourceEventSeqs?: number[]`** — seq numbers of events that are provenance sources (e.g., the `assistant/chunk` seqs that built an `assistant/message`, or the surface nodes shadowed by a compaction marker). A present `[]` is valid only on `assistant/message` and records a known empty provider stream; omission there means legacy or otherwise unrecorded provenance. Other surface events require a non-empty list when the field is present. Provenance is a core design principle; without it, the replace-range operation cannot be validated on replay. - **`surfaceOp?: SurfaceOp`** — how this event entered the surface. Absent for non-surface events. ### SurfaceOp: two operations @@ -25,7 +25,7 @@ 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 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 records `sourceEventSeqs` where applicable: every successful `assistant/message` records its complete `assistant/chunk` source set, including `[]`, while `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. @@ -47,7 +47,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls ### Invariants -The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows). +The dev-mode invariants plugin validates: `sourceEventSeqs` references (only `assistant/message` may use an empty list; otherwise no duplicates, references earlier events, and references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows). Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy. diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml new file mode 100644 index 0000000000..99c6301f25 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-15-replay-token-meter-service.md: 4452c151e122c4a4ad72e3f0bc2616cd2fa28b9d +2026-07-15-replay-token-meter-service.zh.md: 23edc11ffd19b9cfaeb794f3608e9ced4e7dbb7b diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md new file mode 100644 index 0000000000..4452c151e1 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -0,0 +1,57 @@ +# RFC: Replay token meter service + +Status: implemented + +English | [中文](2026-07-15-replay-token-meter-service.zh.md) + +## Problem + +Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of one model's window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse accounting from the wrong model. + +Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch models, old logs can lack chunk provenance, and provider fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines exact anchors with conservative model-specific repricing and exposes the log revision consumed by each result. + +## Decision + +### One concrete LLM-family service + +`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. Its public entry point resolves an exact model name to a stable `ModelTokenMeter`; unknown names throw `TokenMeterError` with `TOKEN_METER_MODEL_UNCONFIGURED` instead of inheriting a universal window. + +The built-in `deepseek-v4-flash` and `deepseek-v4-pro` profiles use a 128,000-token context window and four characters per estimated token. `models` overrides merge field-by-field. A custom name requires `contextWindow`, while `charsPerToken` defaults to four. Direct construction reports typed profile errors; Loader mounts first apply the package's Schemastery shape validation. + +### Model-bound replay folds + +Each model/session pair owns an isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical request headers and deltas, step boundaries, surface appends and replacements, assistant usage, and assistant-chunk provenance. A malformed next event fails transactionally and remains unread rather than partially mutating state. + +`measure(session, requestHeader?)` returns scalar pressure. `measureSurface(session)` returns positional per-node prices for retention and replacement decisions. `estimateMessage(message)` applies the handle's profile without session state. Results are detached, deeply immutable snapshots carrying `logRevision`; a consumer compares scalar and surface revisions before making one decision. + +Provider usage is reused only when the handle's model and canonical request envelope equal the successful-call anchor. Any system, prefix, tool, or call-config change causes complete repricing under the requested model. Surface changes remain a signed delta from a matching anchor, including negative values after a shrinking replacement. A success by another model changes the shared surface but never overwrites this model's anchor. + +Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reasoning is not added a second time. Every successful model call records an `assistant/message`, including content-less and max-token calls, with its exact earlier chunk seqs. An explicit empty provenance list means a known empty provider stream; absent legacy provenance conservatively treats the durable assistant output as provider output. + +### Compact-basic consumes, but does not own, measurement + +`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The conversation model's meter consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection. + +Every metered model receives a compact policy with defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, summarization model `''`, maximum summary output `8192`, one extra compaction attempt, and automatic triggering enabled. Per-model compact overrides merge `thresholdRatio` and `retainTokens`; retention must remain below the resulting threshold. Empty summarization model resolves the latest logged routed model, then `AgentOptions.model`. + +The pre-step trigger measures a provisional envelope: the current prompt and prefix override logged values, while the latest logged header supplies model, tools, and other call config. A model-less router-only agent skips that provisional check because `agent/request` can route later; naming an unknown model remains an error. + +## Testing + +Unit coverage pins profiles, field-wise overrides, custom and unknown models, envelope invalidation, model switching, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, immutable snapshots, listener ordering, reload, compact defaults, routing fallback, retention, convergence, and transaction rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order. + +## Alternatives considered + +- **Keep estimation inside `CompactService`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API. +- **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration. +- **Give unknown models a 128,000-token fallback** — rejected because a plausible but wrong capacity can trigger destructive policy at the wrong point. Unknown routed names fail with their exact name. +- **Copy complete history into each scalar result** — rejected because below-threshold reads are common. Immutable revisioned scalars and a separate surface snapshot preserve consistency without an O(history) copy. +- **Treat provider usage as portable between models or envelopes** — rejected because tokenization, context capacity, tools, prefixes, and call config are model/request facts. Mismatch reprices the whole current request. + +## Consequences + +- Token pressure has one replay-aware owner that compaction and future plugins can share. +- Defaults make the bundled DeepSeek composition usable with two zero-config plugin entries, while custom models must state the one fact that cannot be guessed safely: context capacity. +- Heuristic density and provider usage remain estimates of provider behavior. Maintainers must update built-in profiles and overflow wording as models evolve. +- Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure. +- The pre-step compact integration can skip a router-only first check and can miss tool or routing changes applied later in request middleware. diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md new file mode 100644 index 0000000000..23edc11ffd --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -0,0 +1,57 @@ +# RFC: 重放式 token 计量服务 + +Status: implemented + +[English](2026-07-15-replay-token-meter-service.md) | 中文 + +## 问题 + +上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了某个模型多少上下文窗口?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现重放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方错误复用其他模型的核算结果。 + +提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换模型,旧日志可能缺少 chunk 来源,提供方字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把精确锚点与保守的逐模型重新定价结合起来,并公开每个结果已经消费的日志修订号。 + +## 决策 + +### 一个具体的 LLM 家族服务 + +`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体 package,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。公开入口把精确模型名解析为稳定的 `ModelTokenMeter`;未知名称抛出带 `TOKEN_METER_MODEL_UNCONFIGURED` 的 `TokenMeterError`,而不是继承通用窗口。 + +内置的 `deepseek-v4-flash` 与 `deepseek-v4-pro` profile 都采用 128,000 token 上下文窗口,以及每 token 四个字符的估算密度。`models` 覆盖按字段合并。自定义名称必须提供 `contextWindow`,而 `charsPerToken` 默认为四。直接构造会报告类型化 profile 错误;Loader 挂载则先应用 package 的 Schemastery 形状校验。 + +### 绑定模型的重放折叠 + +每个模型/会话对都有隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范请求头及其增量、步骤边界、表层追加与替换、assistant usage,以及 assistant chunk 来源。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。 + +`measure(session, requestHeader?)` 返回标量压力。`measureSurface(session)` 返回用于保留与替换决策的逐位置节点价格。`estimateMessage(message)` 不依赖会话状态,直接应用该 handle 的 profile。结果是分离且深度不可变的快照,并携带 `logRevision`;消费者在一次联合决策前比较标量与表层修订号。 + +只有当 handle 的模型与规范请求信封都等于成功调用锚点时,服务才复用提供方 usage。系统提示词、前缀、工具或调用配置任一变化都会在请求模型下重新定价完整当前请求。表层变化相对匹配锚点保留有符号增量,包括缩小替换后的负值。其他模型的成功调用会改变共享表层,但绝不会覆盖当前模型的锚点。 + +Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 求和,不会再次加入推理计数。每次成功模型调用都会记录 `assistant/message`,包括无内容调用与达到 token 上限的调用,并带上精确的更早 chunk seq。显式空来源列表表示已知为空的提供方流;旧日志中缺失的来源则保守地把持久 assistant 输出视为提供方输出。 + +### compact-basic 消费计量,但不拥有计量 + +`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。会话模型的 meter 一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝。 + +每个已计量模型都会获得默认压缩策略:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、摘要模型 `''`、摘要最大输出 `8192`、一次额外压缩尝试,以及启用自动触发。逐模型压缩覆盖按字段合并 `thresholdRatio` 与 `retainTokens`;保留值必须小于最终阈值。空摘要模型先解析最近记录的实际路由模型,再使用 `AgentOptions.model`。 + +pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日志值,最近记录的请求头提供模型、工具及其他调用配置。没有模型的纯路由 agent 会跳过该临时检查,因为 `agent/request` 仍可稍后路由;显式命名未知模型仍然报错。 + +## 测试 + +单元覆盖固定 profile、按字段覆盖、自定义与未知模型、信封失效、模型切换、有无 usage 的路径、种子追加/替换重放、有符号增量、来源模式、畸形边界、不可变快照、监听器顺序、重载、压缩默认值、路由回退、保留、收敛与事务回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic package 名称。 + +## 考虑过的替代方案 + +- **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费者与重放语义;它还会强迫每个压缩器暴露同一套无关 API。 +- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的 package 与配置。 +- **给未知模型提供 128,000 token 回退**——不予采纳,因为看似合理但错误的容量会在错误时点触发破坏性策略。未知路由名称会携带精确名称失败。 +- **在每个标量结果中复制完整历史**——不予采纳,因为低于阈值的读取很常见。不可变且带修订号的标量与独立表层快照,在不进行 O(history) 复制的情况下保持一致性。 +- **在模型或信封之间移用提供方 usage**——不予采纳,因为分词、上下文容量、工具、前缀与调用配置都是模型/请求事实。不匹配时会重新定价完整当前请求。 + +## 后果 + +- Token 压力拥有一个可供压缩与未来插件共享的重放感知所有者。 +- 默认值让内置 DeepSeek 组合只需两个零配置插件条目即可使用,而自定义模型必须声明唯一不能安全猜测的事实:上下文容量。 +- 启发式密度与提供方 usage 仍然只是提供方行为的估计。随着模型演进,维护者必须更新内置 profile 与溢出措辞。 +- 遇到畸形持久边界时,计量会明确失败。这会把损坏的重放转化为具名集成错误,而不是让压力静默漂移。 +- pre-step 压缩集成可能跳过纯路由的首次检查,也可能错过请求中间件稍后应用的工具或路由变化。 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 29e3347759..d5d254cf4a 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 @@ -8,7 +8,7 @@ A long-running agent conversation grows without bound. As the event log accumula 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*. -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. +Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../../implemented/architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. 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. ## Decision @@ -17,7 +17,7 @@ Two forces shape the design. First, compaction is **swappable**: token counting Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (chars per token — the `charsPerToken` config, default 4 — + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. 3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation @@ -28,9 +28,9 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" ### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend -An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. +An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the standalone service lets multiple consumers share one model/session replay fold. -`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies the agent, lifecycle context, assembled system prompt (counted toward the estimate), and the turn's abort signal, so optionality would only invite a hidden default at the seam. The session being compacted comes from the agent context. `compactRegion(session, start, end, agent, turn, step, signal?)` keeps an optional signal (a manual caller may omit it). Passing lifecycle context rather than a concrete model keeps router agents honest: the backend's summarization request can run through `agent/request`, where model-routing plugins already choose the actual model. +`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options. ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam @@ -40,7 +40,7 @@ The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired b ``` assembly = ctx.systemPrompt.assemble() -await ctx.serial('agent/pre-step', agent, turn, step, system, signal) ⟵ compaction mutates the surface here +await ctx.serial('agent/pre-step', agent, turn, step, system, prefix, signal) ⟵ compaction mutates the surface here session('step/start') ⟵ the step opens AFTER the seam messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) @@ -64,7 +64,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Approximate convergence invariant -`resolveConfig` validates numeric knobs but does NOT reject based on a pretend summary-length invariant. Convergence is dynamic: provider output caps can be spent on hidden or surfaced reasoning tokens, and the model may emit a summary of unpredictable size. `maxTokens` is only the provider-side generation cap for the summarization call; reasoning blocks are stripped before the checkpoint is stored. If a compacted surface is still over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times, but each committed summary must be smaller than the content it shadows. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. +`resolveConfig` supplies usable common defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization-model override, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. Optional per-model threshold/retention fields merge over those defaults and must name a configured meter profile; retained tokens must be below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If the compacted surface remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary @@ -103,19 +103,19 @@ Two failure paths, both documented: ## Alternatives considered -- **The full algorithm as concrete interface methods** (only estimation/summarization abstract) — the earlier draft; rejected because it recouples the contract to one retention strategy. Both core methods are abstract; the `protected` estimation/summarization hooks are the backend's private factoring, not the contract's. +- **The full algorithm as concrete interface methods** — rejected because it recouples the contract to one retention strategy. Both core methods are abstract; reusable measurement is a separate LLM-family service and `summarize()` is basic's sole hook. - **Compaction on the `agent/request` waterfall** — the earlier cut; rejected for the double-derive it forced and for handing the listener context it structurally cannot compact. The dedicated `agent/pre-step` seam makes the layering correct by construction. - **A separate `compact/error` event** — rejected: `compact/end` keeps an `error?` field, mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling. - **Teaching core turn-repair about `compact/*`** — rejected: the log-only orphan is inert, and a core module patched for every future `xxx/start … xxx/end` plugin pair is exactly the coupling the capability-seam architecture exists to avoid. ## Consequences -- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred. +- **Packages**: `packages/compact/compact` supplies the interface and `compact-basic` supplies the backend. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. - **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-compact`** owns `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and resolves after-edges from its positional successor map instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. - **`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. -- **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). +- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; bundled DeepSeek profiles and compact defaults make the pair usable without repeated numeric policy. ## Testing diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index be9a457124..46788808a5 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -23,6 +23,8 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_coding_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_coding_token_meter plugin_coding_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_coding_compact_basic plugin_coding_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -55,6 +57,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `stdio-agent` | `@deepseek-ai/dsh-stdio-agent` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 5581a910b1..1208480388 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -45,17 +45,14 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Summarize an older range when derived history approaches the context window. -# This leaf consumes `ctx.llm` and the app's `agent/pre-step` seam. +# Replay-aware request pressure for the bundled DeepSeek model profiles. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +# Summarize an older range when measured history approaches the context window. +# Built-in model policies provide the ordinary threshold and retained-tail defaults. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' - config: - contextWindow: 128000 - thresholdRatio: 0.8 - retainTokens: 20480 - summarizationModel: '' - maxTokens: 8192 - compactionRetries: 1 # Expose fresh-child `spawn` and completed-prefix `fork` through independent # in-process backends. Each tool instance needs a distinct `toolName`; the registry diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 932209115f..c2aa809327 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -33,10 +33,15 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // Reasoning tokens require a larger generation cap than the retained checkpoint. ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT, + tokenMeter: { + models: { + 'deepseek-v4-flash': { contextWindow: 2000 }, + }, + }, compact: { - contextWindow: 2000, - thresholdRatio: 0.5, - retainTokens: 400, + models: { + 'deepseek-v4-flash': { thresholdRatio: 0.5, retainTokens: 400 }, + }, summarizationModel: '', maxTokens: 1024, compactionRetries: 1, diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index dd0bc42a1b..923b5efe84 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -10,6 +10,8 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' @@ -46,6 +48,8 @@ export interface CodingHarnessOptions { * compaction plugin (the default suites run without it). */ compact?: BasicCompactConfig + /** Optional meter profiles loaded before compact-basic. */ + tokenMeter?: TokenMeterConfig } export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { @@ -60,9 +64,12 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) - // Compaction is opt-in: only the compaction e2e loads it, with a lowered - // contextWindow/retainTokens so a short real session crosses the threshold. - if (options.compact !== undefined) await ctx.plugin(BasicCompactService, options.compact) + // Compaction is opt-in: only the compaction e2e loads the reusable meter and + // backend, with a lowered profile window so a short real session crosses the threshold. + if (options.compact !== undefined) { + await ctx.plugin(TokenMeterService, options.tokenMeter) + await ctx.plugin(BasicCompactService, options.compact) + } // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the // other suites stay file-free. Loaded last so a resume's deferred // `ctx.inject(['sessionPersistence'])` resolves once this is present. diff --git a/packages/compact/README.md b/packages/compact/README.md index 08c3ddd707..b5f5987571 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -5,7 +5,7 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement | Package | Role | ctx key | |---|---|---| | `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | -| `compact-basic/` | A backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | +| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | | `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) | -The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool. +The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement is a reusable LLM-family service rather than a `CompactService` method; a template- or model-backed compactor can replace `compact-basic` without changing the meter or callers. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index d05ce47356..435e14d875 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-compact-basic -The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a chars-per-token heuristic (the `charsPerToken` config, default 4), token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`). +The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`). This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. @@ -8,7 +8,7 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: -- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state. +- **Measurement** — the effective conversation model's `ModelTokenMeter` prices the provisional request envelope and current surface at one consumed-log revision. The current prompt and prefix override their logged values; the pre-step boundary reuses logged tools and call config. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. @@ -16,41 +16,34 @@ This backend owns the compaction policy: - **Lifecycle** — `compactRegion()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged. -`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly. +`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`. ## Config (`BasicCompactConfig`) -Every knob is **required** except `auto` — there is no concrete data yet to justify default thresholds/budgets, so a consumer states each value explicitly rather than inherit a guessed default. `auto` alone defaults to `true`. +Every common setting is optional. Every model known to `ctx.tokenMeter` receives the default compact policy lazily; named overrides merge only the fields supplied and must name a configured meter profile. | Key | Required | Meaning | |---|---|---| -| `contextWindow` | yes | Context window size in tokens. | -| `thresholdRatio` | yes | Compact when estimated usage exceeds this fraction of the window. | -| `retainTokens` | yes | Tokens of recent context to keep intact. | -| `summarizationModel` | yes | Model for summarization (`''` → use the agent's model). | -| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. | -| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. | -| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | -| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. | +| `models..thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. | +| `models..retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. | +| `summarizationModel` | no (default `''`) | Empty resolves the latest logged routed model, then `AgentOptions.model`. | +| `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. | +| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. | +| `auto` | no (default `true`) | Register the `agent/pre-step` automatic listener. Set `false` for manual-only. | ## Usage ```ts import type { Context } from 'cordis' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' export const name = 'compact-basic' export const inject = ['llm'] export function apply(ctx: Context): void { - ctx.plugin(BasicCompactService, { - contextWindow: 128000, - thresholdRatio: 0.8, - retainTokens: 20480, - summarizationModel: '', - maxTokens: 8192, - compactionRetries: 1, - }) + ctx.plugin(TokenMeterService) + ctx.plugin(BasicCompactService) } ``` @@ -122,8 +115,8 @@ Rules: ## Known Limitations and Deferred Work -- **Token estimation is the chars/`charsPerToken` heuristic** — a marked TODO schedules replacing it with an exact count (a real tokenizer, or provider `usage` fed back) so thresholds track the model's actual budget. -- **`estimatePressure()` does not count the request's `tools` field** — pressure is underestimated by the size of the serialized tool schemas the request also carries. +- **Pre-step sees a provisional request envelope** — the current prompt and prefix are exact, but routing and tool changes made later in `agent/request` are not logged yet. A router-only agent with no provisional model skips that check. +- **Meter accuracy follows the selected profile** — missing provider usage falls back to the token meter's configured character density and structural overhead. - **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting. - **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds. - **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 32852a060f..d50fa45777 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-compact-basic", - "description": "Basic compaction backend (chars-per-token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness", + "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", @@ -26,9 +26,15 @@ "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", @@ -36,6 +42,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/compact/compact-basic/src/automatic.ts b/packages/compact/compact-basic/src/automatic.ts new file mode 100644 index 0000000000..504b0d8a9f --- /dev/null +++ b/packages/compact/compact-basic/src/automatic.ts @@ -0,0 +1,60 @@ +/** + * Automatic pre-step pressure listener for compact-basic. + * + * @module @deepseek-ai/dsh-compact-basic/automatic + */ + +import type { Context } from 'cordis' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { Message } from '@deepseek-ai/dsh-llm' +import { + TOKEN_METER_MODEL_UNCONFIGURED, + TokenMeterError, +} from '@deepseek-ai/dsh-token-meter' +import type { Agent } from '@deepseek-ai/dsh-agent' + +interface AutomaticCompactor { + compactIfNeeded( + agent: Agent, + fullSystemPrompt: string, + sessionPrefix: readonly Message[], + signal: AbortSignal, + ): Promise +} + +/** + * Register the implementation-owned automatic compaction listener. + * @param ctx - context owning the listener effect and logger. + * @param service - compactor whose public methods remain dynamically dispatched. + */ +export function registerAutomaticCompaction( + ctx: Context, + service: AutomaticCompactor, +): void { + ctx.on('agent/pre-step', async ( + agent: Agent, + _turn: number, + _step: number, + fullSystemPrompt: string, + sessionPrefix: readonly Message[], + signal: AbortSignal, + ) => { + try { + const result = await service.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) + if (result !== null) { + ctx.logger.info( + `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + + `~${result.shadowedTokenCount} tokens)`, + ) + } + } catch (error: unknown) { + // A named routed model without a meter profile is configuration failure, + // not an optional operational compaction miss. + if (error instanceof TokenMeterError + && error.code === TOKEN_METER_MODEL_UNCONFIGURED) throw error + const message = error instanceof Error ? error.message : String(error) + ctx.logger.warn(`compaction failed: ${message}; proceeding with full history`) + } + }) +} diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts new file mode 100644 index 0000000000..3169d7f5aa --- /dev/null +++ b/packages/compact/compact-basic/src/config.ts @@ -0,0 +1,117 @@ +/** + * Runtime defaulting and per-model policy validation for compact-basic. + * + * @module @deepseek-ai/dsh-compact-basic/config + */ + +import { deepFreeze } from '@deepseek-ai/dsh-llm' +import type { ModelTokenMeter, TokenMeterService } from '@deepseek-ai/dsh-token-meter' +import type { + BasicCompactConfig, + ModelCompactConfig, + ResolvedConfig, + ResolvedModelCompactConfig, +} from './types.ts' + +/** Default request-pressure fraction for every metered model. */ +const DEFAULT_THRESHOLD_RATIO = 0.8 + +/** Default verbatim-tail fraction of a model's context window. */ +const DEFAULT_RETAIN_RATIO = 0.16 + +/** + * Resolve common defaults and validate every named model override. + * @param config - raw compact-basic configuration. + * @param tokenMeter - owning meter service used to reject unknown override names. + * @returns a detached deeply immutable top-level configuration. + */ +export function resolveConfig( + config: BasicCompactConfig = {}, + tokenMeter: TokenMeterService, +): ResolvedConfig { + const configuredModels: unknown = config.models + const models = configuredModels === undefined ? {} : configuredModels + if (typeof models !== 'object' || models === null || Array.isArray(models)) { + throw new Error('BasicCompactConfig: models must be an object') + } + + const detachedModels: Record = {} + for (const [model, override] of Object.entries(models as Record)) { + if (typeof override !== 'object' || override === null || Array.isArray(override)) { + throw new Error(`BasicCompactConfig: models.${model} must be an object`) + } + const meter = tokenMeter.resolve(model) + detachedModels[model] = { ...override as ModelCompactConfig } + resolveModelConfig({ + models: detachedModels, + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, + auto: true, + }, meter) + } + + const resolved: ResolvedConfig = { + models: detachedModels, + summarizationModel: config.summarizationModel ?? '', + maxTokens: config.maxTokens ?? 8192, + compactionRetries: config.compactionRetries ?? 1, + auto: config.auto ?? true, + } + assertPositiveInteger('maxTokens', resolved.maxTokens) + assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) + if (typeof resolved.summarizationModel !== 'string') { + throw new Error('BasicCompactConfig: summarizationModel must be a string') + } + if (typeof resolved.auto !== 'boolean') { + throw new Error('BasicCompactConfig: auto must be a boolean') + } + return deepFreeze(structuredClone(resolved)) +} + +/** + * Resolve one effective model's default policy plus optional field overrides. + * @param config - validated compact-basic configuration. + * @param meter - effective model's token-meter handle and context capacity. + * @returns a detached immutable model policy. + */ +export function resolveModelConfig( + config: ResolvedConfig, + meter: ModelTokenMeter, +): ResolvedModelCompactConfig { + const override = config.models[meter.model] + const thresholdRatio = override?.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO + const retainTokens = override?.retainTokens ?? Math.floor(meter.contextWindow * DEFAULT_RETAIN_RATIO) + assertRatio(`models.${meter.model}.thresholdRatio`, thresholdRatio) + assertNonNegativeInteger(`models.${meter.model}.retainTokens`, retainTokens) + const thresholdTokens = Math.floor(meter.contextWindow * thresholdRatio) + if (retainTokens >= thresholdTokens) { + throw new Error( + `BasicCompactConfig: models.${meter.model}.retainTokens (${retainTokens}) must be less than threshold tokens ${thresholdTokens}`, + ) + } + return deepFreeze({ + model: meter.model, + contextWindow: meter.contextWindow, + thresholdRatio, + retainTokens, + }) +} + +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer`) + } +} + +function assertNonNegativeInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer`) + } +} + +function assertRatio(name: string, value: number): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1]`) + } +} diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index fffc8e9a63..f8a9060aa9 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -1,286 +1,120 @@ /** - * 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. + * Basic replay-aware compaction backend. + * * @module @deepseek-ai/dsh-compact-basic */ import { Context } from 'cordis' -import { CompactService, renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' +import z from 'schemastery' +import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { canonicalHeader } from '@deepseek-ai/dsh-session' +import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' +import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { BasicCompactConfig, ResolvedConfig } from './types.ts' -import { resolveConfig } from './types.ts' +import { registerAutomaticCompaction } from './automatic.ts' +import { resolveConfig, resolveModelConfig } from './config.ts' +import { compactSurfaceRegion, selectCompactableRange } from './region.ts' +import { summarizeWithLlm } from './summarizer.ts' +import type { + BasicCompactConfig, + ResolvedConfig, + ResolvedModelCompactConfig, +} from './types.ts' -export type { BasicCompactConfig, ResolvedConfig } from './types.ts' -export { resolveConfig } from './types.ts' +export { resolveConfig, resolveModelConfig } from './config.ts' +export type { + BasicCompactConfig, + ModelCompactConfig, + ResolvedConfig, + ResolvedModelCompactConfig, +} from './types.ts' -/** Per-block structural overhead for JSON framing / type tag. */ -const BLOCK_OVERHEAD = 4 - -/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */ -const ROLE_OVERHEAD = 4 - -/** Tags wrapping the structured summary inside the landed checkpoint node. */ -const SUMMARY_OPEN_TAG = '' -const SUMMARY_CLOSE_TAG = '' - -/** - * 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.', - '', - 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.', - '', - '## Primary Request and Intent', - "- [the user's original and evolving goals; quote verbatim where the exact wording matters]", - '', - '## Key Technical Concepts', - '- [technologies, frameworks, patterns, and conventions in play]', - '', - '## Files and Code', - '- [exact path: why it matters, key changes or snippets]', - '', - '## Errors and Fixes', - '- [error: how it was resolved, plus any related user feedback]', - '', - '## Pending Tasks', - '- [explicitly requested work not yet completed]', - '', - '## Current Work', - '- [precisely what was in progress at this checkpoint]', - '', - '## Next Step', - '- [the single next action, directly in line with the most recent request, or "(none)"]', - '', - '## Critical Context', - '- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]', - '', - 'Rules:', - '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.', - '- Capture user feedback and explicit instructions faithfully, especially corrections.', - '- Do NOT mention this summarization process or that the context was compacted.', - `- 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 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 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) { - case 'error': { - const error = new Error(finish.message) as Error & { code?: string } - if (finish.code !== undefined) error.code = finish.code - return error - } - case 'aborted': { - const error = new Error('summarization stream aborted') as Error & { code?: string } - error.code = 'ABORTED' - return error - } - case 'max-tokens': { - const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string } - error.code = 'MAX_TOKENS' - return error - } - default: - return undefined - } +/** Resolve the latest actual routed model, then the agent's configured fallback. */ +function effectiveModel(agent: Agent): string | undefined { + return agent.session.requestHeader()?.config.model ?? agent.options.model } /** - * Basic, dependency-light compaction backend: estimates the surface's token - * footprint, summarizes the stale prefix through the model, and shadows it - * behind a durable checkpoint. Every threshold/budget knob is required config - * ({@link BasicCompactConfig}); the estimator's text density is the - * `charsPerToken` knob. + * Build the provisional pre-step request envelope. Prompt and prefix are exact; + * tools and non-model call config come from the latest logged request because + * later request middleware has not run yet. + */ +function provisionalHeader( + model: string, + session: Session, + fullSystemPrompt: string, + sessionPrefix: readonly Message[], +): EpochHeader { + const latest = session.requestHeader() + return canonicalHeader({ + config: latest === undefined ? { model } : { ...latest.config, model }, + ...fullSystemPrompt.length === 0 ? {} : { system: fullSystemPrompt }, + ...latest?.tools === undefined ? {} : { tools: latest.tools }, + ...sessionPrefix.length === 0 ? {} : { messagePrefix: [...sessionPrefix] }, + }) +} + +/** + * Dependency-light compaction backend using `ctx.tokenMeter` for pressure, + * retention, provenance, and summary-convergence pricing. + * + * `summarize()` is the sole subclass customization hook; the replay and durable + * mutation strategy stays fixed so every pricing decision uses one effective + * conversation-model meter. */ export class BasicCompactService extends CompactService { - static inject = ['llm'] + static inject = ['llm', 'tokenMeter'] - /** Resolved configuration (`auto` defaulted). */ + static Config: z = z.object({ + models: z.dict(z.object({ + thresholdRatio: z.number(), + retainTokens: z.number().step(1), + })), + summarizationModel: z.string().default(''), + maxTokens: z.number().step(1).min(1).default(8192), + compactionRetries: z.number().step(1).min(0).default(1), + auto: z.boolean().default(true), + }) + + /** Resolved and validated common configuration plus named partial overrides. */ readonly config: ResolvedConfig - constructor(ctx: Context, config: BasicCompactConfig) { + private readonly modelConfigs = new Map() + + constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) - this.config = resolveConfig(config) - - if (this.config.auto) { - // 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) - if (result) { - const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix) - ctx.logger.info( - `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + - `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + - `~${result.shadowedTokenCount} tokens) ` + - `→ ${after} estimated tokens after compaction`, - ) - } - } catch (error: unknown) { - // A failed compaction must not prevent the model call — the surface is - // untouched on failure, so the loop derives the full history and the - // call proceeds. - const msg = error instanceof Error ? error.message : String(error) - ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`) - } - }) - } - } - - // ---- Token estimation (overridable hooks) ---- - - // TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact - // count — a real tokenizer, or the provider's post-response `usage` (input - // tokens) fed back as a correction — so threshold decisions match the - // model's actual budget. - /** - * Estimate the token count of content blocks — chars divided by the - * `charsPerToken` config, with per-block overhead. Override in a subclass to - * plug in a real tokenizer. - * - * @param blocks - the blocks to estimate; `tool-result` blocks recurse into - * their nested content, and unknown (merge-extended) types fall back to - * their JSON-stringified length. - * @returns the estimated token count. - */ - estimateContentTokens(blocks: readonly ContentBlock[]): number { - const { charsPerToken } = this.config - let tokens = 0 - for (const block of blocks) { - switch (block.type) { - case 'text': - case 'reasoning': - tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD - break - case 'tool-call': - tokens += Math.ceil(block.name.length / charsPerToken) - + Math.ceil(block.arguments.length / charsPerToken) - + BLOCK_OVERHEAD - break - case 'tool-result': - tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD - break - default: - // Unknown block types (merge-extensible ContentBlockMap): - // estimate conservatively via JSON stringify. - tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken) - } - } - return tokens + this.config = resolveConfig(config, ctx.tokenMeter) + if (this.config.auto) registerAutomaticCompaction(ctx, this) } /** - * Estimate token count for a single session event. Returns 0 for non-message - * event types (boundaries, chunks, usage, errors, compact markers). - * - * @param event - any session event; only the message-bearing types carry - * content to count. - * @returns the estimated token count of the event's content, or 0 for a - * non-message event. - */ - estimateEventTokens(event: SessionEvent): number { - switch (event.type) { - case 'user/message': - case 'assistant/message': - case 'context/message': - case 'steering/message': - case 'tool/result': - return this.estimateContentTokens(event.data.content) - default: - return 0 - } - } - - /** - * Estimate total tokens across a list of messages plus optional system prompt. - * - * @param messages - the derived conversation messages; each adds a fixed - * role-framing overhead on top of its content estimate. - * @param systemPrompt - counted at chars / `charsPerToken` when provided. - * @returns the estimated token footprint of the whole request. - */ - estimateTokens(messages: readonly Message[], systemPrompt?: string): number { - let total = 0 - for (const msg of messages) { - total += this.estimateContentTokens(msg.content) - total += ROLE_OVERHEAD - } - if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken) - return total - } - - /** - * 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 - * the call; throws when neither it nor the config names a model. - * @param signal - optional abort signal, forwarded into the model call. - * @returns the text-only summary blocks plus the call envelope used - * (`model`, and `maxTokens` when the summarizer has a cap). + * Summarize a rendered region through a direct one-shot `ctx.llm.stream()` + * call. Override this sole hook for a template or remote summarizer. + * @param text - plain-text conversation region to condense. + * @param agent - supplies routed-model history, fallback model, and session id. + * @param signal - optional cancellation forwarded to the adapter. + * @returns safe text summary blocks and exact auxiliary-call provenance. */ async summarize( - text: string, agent: Agent, signal?: AbortSignal, + text: string, + agent: Agent, + signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> { - const assembler = new BlockAssembler() - const options: GenerateOptions = { - model: this.config.summarizationModel || agent.options.model || '', - messages: [{ - role: 'user', - content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], - }], - system: SUMMARIZE_SYSTEM_PROMPT, - maxTokens: this.config.maxTokens, - sessionId: agent.session.id, - } - // exactOptionalPropertyTypes: only set `signal` when present — assigning - // `undefined` to an optional `signal?: AbortSignal` is a type error. - if (signal) options.signal = signal - if (!options.model) { - throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel or AgentOptions.model') - } - for await (const chunk of this.ctx.llm.stream(options)) { - assembler.push(chunk) - } - - const error = finishError(assembler.finish) - if (error) throw error - - const summary = this._textOnly(assembler.message().content) - if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) { - throw new Error('summarization produced no text summary content') - } - - // config.maxTokens is required and validated positive, so this backend's - // envelope always carries the cap; the return type's optionality exists - // for overriding subclasses whose summarizer has none. - return { summary, model: options.model, maxTokens: this.config.maxTokens } + return summarizeWithLlm(this.ctx, this.config, text, agent, signal) } - // ---- Core API (implements the abstract contract) ---- - /** - * 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. + * Check replayed pressure for the provisional pre-step envelope and compact + * a tool-balanced head until it falls below the effective model threshold. + * A genuinely model-less router-first step skips this provisional check; + * naming an unconfigured model throws the token meter's typed error. + * @param agent - agent whose session and provisional model are measured. + * @param fullSystemPrompt - current assembled system prompt override. + * @param sessionPrefix - current request-only prefix override. + * @param signal - live step cancellation signal forwarded to summarization. + * @returns the latest compaction result, or `null` when no check/work applies. */ override async compactIfNeeded( agent: Agent, @@ -288,47 +122,51 @@ export class BasicCompactService extends CompactService { sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise { - const session = agent.session - const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) - let result: CompactionResult | null = null - for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) { - const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix) - if (totalTokens < threshold) return result + const model = effectiveModel(agent) + if (model === undefined || model.length === 0) return null + const meter = this.ctx.tokenMeter.resolve(model) + const policy = this._modelConfig(meter) + const requestHeader = provisionalHeader(model, agent.session, fullSystemPrompt, sessionPrefix) + const threshold = Math.floor(policy.contextWindow * policy.thresholdRatio) + let measurement = meter.measure(agent.session, requestHeader) + if (measurement.totalTokens < threshold) return null - const range = this._compactableRange(session) + let result: CompactionResult | null = null + for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) { + const surface = meter.measureSurface(agent.session) + if (surface.logRevision !== measurement.logRevision) { + throw new Error( + `compaction: pressure revision ${measurement.logRevision} does not match surface revision ${surface.logRevision}`, + ) + } + const range = selectCompactableRange(agent.session, surface, policy.retainTokens) if (range === null) { - /* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */ + /* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */ if (result === null) return null - /* v8 ignore next -- paired with the ignored defensive branch above. */ + /* v8 ignore next -- paired with the defensive post-success branch above. */ break } - - result = await this.compactRegion(session, range.start, range.end, agent, signal) + result = await this.compactRegion(agent.session, range.start, range.end, agent, signal) + measurement = meter.measure(agent.session, requestHeader) + if (measurement.totalTokens < threshold) return result } - const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix) - if (totalTokens < threshold) return result - throw new Error( `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts ` - + `(${totalTokens} estimated tokens >= threshold ${threshold})`, + + `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`, ) } /** - * Estimated token pressure of the NEXT request: the session prefix - * (`EpochHeader.messagePrefix` — request-only messages the loop sends in - * front of the derived history, composed before the pre-step seam and - * handed to the gate), the derived history, and the system prompt. - * @param session - the session whose next request is being estimated. - * @param fullSystemPrompt - the assembled system prompt (counts toward pressure). - * @param sessionPrefix - the instance's composed session prefix (counts toward pressure). - * @returns the estimated token total the next request will carry. + * Compact one inclusive positional surface range using the effective + * conversation model for all retention and shrink pricing. + * @param session - session whose surface is mutated. + * @param start - inclusive first surface-node seq. + * @param end - inclusive last surface-node seq. + * @param agent - agent used by the summarizer and model resolver. + * @param signal - optional summarization cancellation signal. + * @returns the successful durable compaction result. */ - estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number { - return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt) - } - override async compactRegion( session: Session, start: number, @@ -336,214 +174,26 @@ export class BasicCompactService extends CompactService { agent: Agent, signal?: AbortSignal, ): Promise { - // Resolve by surface position: a newer replacement seq may occupy an older slot. - const nodes = session.surface.nodes - const startIdx = nodes.findIndex(n => n.seq === start) - const endIdx = nodes.findIndex(n => n.seq === 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) { - throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`) - } - - // Both range edges must preserve assistant tool-call/result pairing. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const startNode = nodes[startIdx]! - if (!toolPairingBalancedBefore(session, startNode)) { - throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`) - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const endNode = nodes[endIdx]! - if (!toolPairingBalancedAfter(session, endNode)) { - throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`) - } - - if (this._isCompactionInProgress(session)) { - 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. - const openTurn = this._openTurn(session) - if (openTurn === null) { - throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') - } - // 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) - - // --- Acquire lock --- - const startEvent = session.append('compact/start', { turn: openTurn }) - - try { - // --- Extract text and summarize --- - const text = renderTranscript(session.events, shadowedSeqs) - const { summary, model, maxTokens } = await this.summarize(text, agent, signal) - - // Estimate token count of the shadowed content for provenance. - let shadowedTokenCount = 0 - for (const seq of shadowedSeqs) { - // seq comes from a surface node — always a valid log index by construction. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - shadowedTokenCount += this.estimateEventTokens(session.events[seq]!) - } - const framedSummary = this._frameSummary(summary) - const framedSummaryTokenCount = this.estimateContentTokens(framedSummary) - if (framedSummaryTokenCount >= shadowedTokenCount) { - throw new Error( - `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`, - ) - } - // --- Provenance record (log-only) --- - const summaryEvent = session.append('compact/summary', { - summary, - shadowedRange: { start, end }, - shadowedSeqs, - shadowedTokenCount, - model, - ...maxTokens !== undefined ? { maxTokens } : {}, - }) - - // --- 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' }, - }, { - surfaceOp: { op: 'replace', start, end }, - sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], - }) - - // --- Release lock (log-only) --- - // Appended LAST so the lock brackets the WHOLE operation: a crash between - // compact/start and here leaves a detectable orphaned lock (a compact/start - // with no matching compact/end) rather than a compact/end that falsely - // claims compaction finished before the surface replacement landed. - const endEvent = session.append('compact/end', { turn: openTurn }) - - return { - startSeq: startEvent.seq, - summarySeq: summaryEvent.seq, - endSeq: endEvent.seq, - summary, - shadowedRange: { start, end }, - shadowedSeqs, - shadowedTokenCount, - } - } catch (error: unknown) { - // Always release the lock — append compact/end with the error so a - // wedged lock is impossible. - const msg = error instanceof Error ? error.message : String(error) - session.append('compact/end', { turn: openTurn, error: msg }) - throw error + const model = effectiveModel(agent) + if (model === undefined || model.length === 0) { + throw new Error('compactRegion: no routed or configured conversation model is available for token pricing') } + const meter = this.ctx.tokenMeter.resolve(model) + this._modelConfig(meter) + return compactSurfaceRegion({ + meter, + summarize: (text, owner, abort) => this.summarize(text, owner, abort), + }, session, start, end, agent, signal) } - // ---- Internal helpers ---- - - /** - * Frame the raw summary blocks into the content that lands on the surface: - * a checkpoint preamble (so a resuming model reads it as a checkpoint, not a - * fresh user request) followed by the summary wrapped in - * {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior - * checkpoint detectable in the transcript on the next compaction cycle, which - * triggers the merge rule in the summarization prompt. The raw, unframed - * `summary` is preserved separately on the `compact/summary` provenance event. - */ - private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] { - return [ - { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` }, - ...summary, - { type: 'text', text: SUMMARY_CLOSE_TAG }, - ] - } - - /** - * 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 - for (let i = events.length - 1; i >= 0; i--) { - // Index bounded by i >= 0 and i < events.length — never undefined. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const e = events[i]! - if (e.type === 'compact/start') return true - if (e.type === 'compact/end') break - // A turn/end bounds the scan: anything before it belongs to a prior - // (closed) turn and cannot be an in-progress compaction of THIS turn. - if (e.type === 'turn/end') break + /** Resolve and memoize one lazy default/override model policy. */ + private _modelConfig(meter: ModelTokenMeter): ResolvedModelCompactConfig { + let modelConfig = this.modelConfigs.get(meter.model) + if (modelConfig === undefined) { + modelConfig = resolveModelConfig(this.config, meter) + this.modelConfigs.set(meter.model, modelConfig) } - return false - } - - /** Resolve the next head-anchored compactable surface range, or `null`. */ - private _compactableRange(session: Session): { start: number; end: number } | null { - const nodes = session.surface.nodes - if (nodes.length === 0) return null - - const events = session.events - const retainBudget = this.config.retainTokens - - // Walk tail→head summing per-node token estimates. `keepFromIdx` is the - // index of the OLDEST node we retain verbatim; everything strictly older - // (`[0, keepFromIdx - 1]`) is the compactable range. - let accumulated = 0 - 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 */ - if (event) accumulated += this.estimateEventTokens(event) - keepFromIdx = i - if (accumulated >= retainBudget) break - } - - // 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. - while (keepFromIdx > 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (toolPairingBalancedBefore(session, 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 - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cutoffSeq = nodes[keepFromIdx - 1]!.seq - return { start: firstSeq, end: cutoffSeq } - } - - /** 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') - } - - /** - * The turn number of the currently OPEN turn — a `turn/start` not yet - * followed by its `turn/end` — or `null` if the session has no open turn. - * - * Compaction's events must be enclosed in a turn, so scanning back from the - * tail: a `turn/start` means that turn is open (return it); a `turn/end` means - * the most recent turn already closed (return null). The whole compaction - * sequence (compact/start … compact/end) is stamped with this turn. - */ - private _openTurn(session: Session): number | null { - for (let i = session.events.length - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const e = session.events[i]! - if (e.type === 'turn/start') return e.data.turn - if (e.type === 'turn/end') return null - } - return null + return modelConfig } } diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts new file mode 100644 index 0000000000..1bc9b7b0a7 --- /dev/null +++ b/packages/compact/compact-basic/src/region.ts @@ -0,0 +1,196 @@ +/** + * Surface retention selection and the log-recorded compaction transaction. + * + * @module @deepseek-ai/dsh-compact-basic/region + */ + +import { + renderTranscript, + toolPairingBalancedAfter, + toolPairingBalancedBefore, +} from '@deepseek-ai/dsh-compact' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { ModelTokenMeter, TokenSurfaceMeasurement } from '@deepseek-ai/dsh-token-meter' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { frameSummary } from './summarizer.ts' +import type { SummaryResult } from './summarizer.ts' + +interface RegionDependencies { + readonly meter: ModelTokenMeter + summarize(text: string, agent: Agent, signal?: AbortSignal): Promise +} + +/** + * Resolve the next head-anchored range while retaining a priced recent tail + * and never splitting an assistant tool-call/result pair. + * @param session - session supplying authoritative current surface positions. + * @param pricedSurface - same-revision surface measurement from the conversation meter. + * @param retainTokens - minimum recent tail budget retained verbatim. + * @returns the inclusive positional seq range to compact, or `null`. + */ +export function selectCompactableRange( + session: Session, + pricedSurface: TokenSurfaceMeasurement, + retainTokens: number, +): { start: number; end: number } | null { + const pricedNodes = pricedSurface.nodes + if (pricedNodes.length === 0) return null + + const surfaceNodes = session.surface.nodes + if (surfaceNodes.length !== pricedNodes.length + || surfaceNodes.some((node, index) => node.seq !== pricedNodes[index]?.seq)) { + throw new Error('compaction: token-meter surface does not match the current session surface') + } + + let accumulated = 0 + let keepFromIdx = pricedNodes.length + for (let index = pricedNodes.length - 1; index >= 0; index -= 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + accumulated += pricedNodes[index]!.tokens + keepFromIdx = index + if (accumulated >= retainTokens) break + } + if (keepFromIdx === 0) return null + + while (keepFromIdx > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break + keepFromIdx -= 1 + } + if (keepFromIdx === 0) return null + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const first = surfaceNodes[0]! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cutoff = surfaceNodes[keepFromIdx - 1]! + return { start: first.seq, end: cutoff.seq } +} + +/** + * Validate and compact one positional surface span. + * @param dependencies - conversation meter and dynamically dispatched summarizer hook. + * @param session - session whose surface is mutated. + * @param start - inclusive first surface-node seq. + * @param end - inclusive last surface-node seq. + * @param agent - agent used by the summarizer. + * @param signal - optional summarization cancellation signal. + * @returns the successful durable compaction result. + */ +export async function compactSurfaceRegion( + dependencies: RegionDependencies, + session: Session, + start: number, + end: number, + agent: Agent, + signal?: AbortSignal, +): Promise { + const nodes = session.surface.nodes + const startIdx = nodes.findIndex(node => node.seq === start) + const endIdx = nodes.findIndex(node => node.seq === 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) { + throw new Error( + `compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`, + ) + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) { + throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`) + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) { + throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`) + } + + const tail = inspectTurnTail(session.events) + if (tail.compactionInProgress) throw new Error('compaction already in progress') + if (tail.turn === null) { + throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') + } + + const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(node => node.seq) + const startEvent = session.append('compact/start', { turn: tail.turn }) + try { + // Capture after the lock event so any later durable append, including a + // log-only one, invalidates the async selection before replacement. + const lockedSurface = dependencies.meter.measureSurface(session) + const selected = lockedSurface.nodes.slice(startIdx, endIdx + 1) + if (selected.length !== shadowedSeqs.length + || selected.some((node, index) => node.seq !== shadowedSeqs[index])) { + throw new Error('compaction: selected surface changed before summarization began') + } + const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0) + const text = renderTranscript(session.events, shadowedSeqs) + const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal) + + const currentSurface = dependencies.meter.measureSurface(session) + if (currentSurface.logRevision !== lockedSurface.logRevision) { + throw new Error('compaction: session log changed during summarization') + } + const framedSummary = frameSummary(summary) + const framedSummaryTokenCount = dependencies.meter.estimateMessage({ + role: 'user', + content: framedSummary, + }) + if (framedSummaryTokenCount >= shadowedTokenCount) { + throw new Error( + `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`, + ) + } + + const summaryEvent = session.append('compact/summary', { + summary, + shadowedRange: { start, end }, + shadowedSeqs, + shadowedTokenCount, + model, + ...maxTokens === undefined ? {} : { maxTokens }, + }) + session.append('user/message', { + content: framedSummary, + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start, end }, + sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], + }) + const endEvent = session.append('compact/end', { turn: tail.turn }) + return { + startSeq: startEvent.seq, + summarySeq: summaryEvent.seq, + endSeq: endEvent.seq, + summary, + shadowedRange: { start, end }, + shadowedSeqs, + shadowedTokenCount, + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + session.append('compact/end', { turn: tail.turn, error: message }) + throw error + } +} + +/** Inspect the current turn boundary and latest compaction bracket once. */ +function inspectTurnTail( + events: readonly SessionEvent[], +): { turn: number | null; compactionInProgress: boolean } { + let compactionInProgress = false + let compactionStateKnown = false + for (let index = events.length - 1; index >= 0; index -= 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[index]! + if (!compactionStateKnown) { + if (event.type === 'compact/start') { + compactionInProgress = true + compactionStateKnown = true + } else if (event.type === 'compact/end') { + compactionStateKnown = true + } + } + if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress } + if (event.type === 'turn/end') return { turn: null, compactionInProgress } + } + return { turn: null, compactionInProgress } +} diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts new file mode 100644 index 0000000000..359421f0f5 --- /dev/null +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -0,0 +1,153 @@ +/** + * Default one-shot summarization and durable checkpoint framing. + * + * @module @deepseek-ai/dsh-compact-basic/summarizer + */ + +import type { Context } from 'cordis' +import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ResolvedConfig } from './types.ts' + +/** Tags wrapping the structured summary inside the landed checkpoint node. */ +const SUMMARY_OPEN_TAG = '' +const SUMMARY_CLOSE_TAG = '' + +/** Fixed structure required from the auxiliary summarization call. */ +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.', + '', + 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.', + '', + '## Primary Request and Intent', + "- [the user's original and evolving goals; quote verbatim where the exact wording matters]", + '', + '## Key Technical Concepts', + '- [technologies, frameworks, patterns, and conventions in play]', + '', + '## Files and Code', + '- [exact path: why it matters, key changes or snippets]', + '', + '## Errors and Fixes', + '- [error: how it was resolved, plus any related user feedback]', + '', + '## Pending Tasks', + '- [explicitly requested work not yet completed]', + '', + '## Current Work', + '- [precisely what was in progress at this checkpoint]', + '', + '## Next Step', + '- [the single next action, directly in line with the most recent request, or "(none)"]', + '', + '## Critical Context', + '- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]', + '', + 'Rules:', + '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.', + '- Capture user feedback and explicit instructions faithfully, especially corrections.', + '- Do NOT mention this summarization process or that the context was compacted.', + `- 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 that makes the replacement user message established context. */ +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.' + +/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */ +export interface SummaryResult { + summary: ContentBlock[] + model: string + maxTokens?: number +} + +/** + * Run the default direct `ctx.llm.stream()` summarization call. + * @param ctx - context providing the LLM service. + * @param config - resolved backend configuration. + * @param text - rendered transcript region to summarize. + * @param agent - supplies routed-model history, fallback model, and session id. + * @param signal - optional cancellation forwarded to the adapter. + * @returns safe text-only summary blocks and exact call provenance. + */ +export async function summarizeWithLlm( + ctx: Context, + config: ResolvedConfig, + text: string, + agent: Agent, + signal?: AbortSignal, +): Promise { + const latestModel = agent.session.requestHeader()?.config.model + const model = config.summarizationModel || latestModel || agent.options.model || '' + if (model.length === 0) { + throw new Error( + 'no model available for summarization: set BasicCompactConfig.summarizationModel, route one request, or set AgentOptions.model', + ) + } + + const assembler = new BlockAssembler() + const options: GenerateOptions = { + model, + messages: [{ + role: 'user', + content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], + }], + system: SUMMARIZE_SYSTEM_PROMPT, + maxTokens: config.maxTokens, + sessionId: agent.session.id, + ...signal === undefined ? {} : { signal }, + } + for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk) + const error = finishError(assembler.finish) + if (error !== undefined) throw error + + const summary = textOnly(assembler.message().content) + if (!summary.some(block => block.text.trim().length > 0)) { + throw new Error('summarization produced no text summary content') + } + return { summary, model, maxTokens: config.maxTokens } +} + +/** + * Wrap raw summary blocks in the durable checkpoint framing. + * @param summary - safe text-only model output. + * @returns content for the synthesized replacement user message. + */ +export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] { + return [ + { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` }, + ...summary, + { type: 'text', text: SUMMARY_CLOSE_TAG }, + ] +} + +/** Map a terminal summarization finish to its fail-closed error. */ +function finishError(finish: FinishReason): Error | undefined { + switch (finish.kind) { + case 'error': { + const error = new Error(finish.message) as Error & { code?: string } + if (finish.code !== undefined) error.code = finish.code + return error + } + case 'aborted': { + const error = new Error('summarization stream aborted') as Error & { code?: string } + error.code = 'ABORTED' + return error + } + case 'max-tokens': { + const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string } + error.code = 'MAX_TOKENS' + return error + } + default: + return undefined + } +} + +/** Keep only text blocks before synthesizing a user message. */ +function textOnly( + blocks: readonly ContentBlock[], +): Array> { + return blocks.filter((block): block is Extract => block.type === 'text') +} diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 05169286d7..44ff06435d 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -1,94 +1,44 @@ /** - * Configuration vocabulary for the basic compaction backend. - * - * Every tunable lives here, in the implementation — the abstract contract - * (`@deepseek-ai/dsh-compact`) carries no config, because thresholds and - * retention policy are HOW decisions a different backend would make - * differently. + * Configuration vocabulary for the replay-aware basic compaction backend. * * @module @deepseek-ai/dsh-compact-basic/types */ -/** - * Backend configuration. Every knob is REQUIRED except `auto` and - * `charsPerToken`: there is no concrete data yet to justify default - * thresholds/budgets, so a consumer must state each value explicitly rather - * than inherit a guessed default. `auto` alone defaults to `true` - * (auto-compaction is the intended posture), and `charsPerToken` defaults to - * the English-text heuristic its estimator was calibrated on. - */ +/** Optional pressure and retention policy for one metered model. */ +export interface ModelCompactConfig { + /** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */ + thresholdRatio?: number + /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + retainTokens?: number +} + +/** Basic compaction configuration; every common field has a deployment default. */ export interface BasicCompactConfig { - /** Context window size in tokens. */ - contextWindow: number - /** Compact when estimated token usage exceeds this fraction of context window. */ - thresholdRatio: number - /** Number of tokens of recent context to retain during compaction. */ - retainTokens: number - /** Model to use for summarization (`''` — uses the agent's model). */ - summarizationModel: string - /** Provider generation cap for the summarization call. */ - maxTokens: number - /** Extra compaction attempts when the first compacted surface is still over threshold. */ - compactionRetries: number - /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ + /** Field-wise pressure/retention overrides keyed by configured token-meter model name. */ + models?: Record + /** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */ + summarizationModel?: string + /** Provider generation cap for summarization. Defaults to `8192`. */ + maxTokens?: number + /** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */ + compactionRetries?: number + /** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */ auto?: boolean - /** - * Text density for the token estimator: estimated tokens = chars / - * `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy - * deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so - * the default UNDERestimates several-fold and compaction fires far too late. - * May be fractional. - */ - charsPerToken?: number } -/** Resolved config with `auto` and `charsPerToken` defaulted. */ -export type ResolvedConfig = Required - -/** - * Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs. - * - * @param config - the raw, unresolved backend config. - * @returns the validated config with `auto` and `charsPerToken` defaulted. - */ -export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { - const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config } - - assertPositiveInteger('contextWindow', resolved.contextWindow) - assertRatio('thresholdRatio', resolved.thresholdRatio) - assertNonNegativeInteger('retainTokens', resolved.retainTokens) - assertPositiveInteger('maxTokens', resolved.maxTokens) - assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) - assertPositiveFinite('charsPerToken', resolved.charsPerToken) - if (typeof resolved.summarizationModel !== 'string') { - throw new Error('BasicCompactConfig: summarizationModel must be a string.') - } - if (typeof resolved.auto !== 'boolean') { - throw new Error('BasicCompactConfig: auto must be a boolean.') - } - return resolved +/** Validated top-level defaults plus detached per-model partial overrides. */ +export interface ResolvedConfig { + readonly models: Readonly>> + readonly summarizationModel: string + readonly maxTokens: number + readonly compactionRetries: number + readonly auto: boolean } -function assertPositiveInteger(name: string, value: number): void { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer.`) - } -} - -function assertNonNegativeInteger(name: string, value: number): void { - if (!Number.isInteger(value) || value < 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer.`) - } -} - -function assertPositiveFinite(name: string, value: number): void { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`) - } -} - -function assertRatio(name: string, value: number): void { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`) - } +/** Fully resolved pressure/retention policy for one effective model. */ +export interface ResolvedModelCompactConfig { + readonly model: string + readonly contextWindow: number + readonly thresholdRatio: number + readonly retainTokens: number } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 8c5cc183c1..4feb981237 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,1682 +1,797 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import BasicCompactService, { + resolveConfig, + resolveModelConfig, +} from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' +import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import TokenMeterService, { + TOKEN_METER_MODEL_UNCONFIGURED, + TokenMeterError, +} from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' -/** A never-aborted signal for the required `compactIfNeeded`/listener arg. */ const SIGNAL = new AbortController().signal +const MODEL = 'test-model' -/** - * Baseline config with every required knob set. `BasicCompactConfig` has no - * defaults for the numeric/model knobs (only `auto` defaults), so each test - * builds a complete config via `cfg()` and overrides only the knob under test. - */ -const TEST_CONFIG: BasicCompactConfig = { - contextWindow: 128000, - thresholdRatio: 0.8, - retainTokens: 20480, - summarizationModel: '', - maxTokens: 8192, - compactionRetries: 1, -} - -/** A complete config with `overrides` applied over the baseline. */ -function cfg(overrides: Partial = {}): BasicCompactConfig { - return { ...TEST_CONFIG, ...overrides } -} - -/** Long enough that the real checkpoint preamble is smaller than two fixture messages. */ -const LONG_FIXTURE_TEXT = ' Detailed fixture context that makes framed checkpoint compaction genuinely shrinking.'.repeat(20) - -/** - * A BasicCompactService with summarize() stubbed (no real model call) and a - * predictable token estimate, for deterministic unit tests of the algorithm. - */ -class TestCompactService extends BasicCompactService { - private readonly summaryOutputs = new WeakSet() - /** Boundary/unit tests use tiny fixtures; keep framing from dominating them unless a test opts out. */ - estimateFramedSummariesCheaply = true - /** Track calls to summarize for test assertions. */ - summarizeCalls: { text: string; model: string }[] = [] - /** The fixed summary to return. */ - mockSummary: ContentBlock[] = [{ type: 'text', text: 'Test summary of compacted content.' }] - /** Per-call summaries; when set, each summarize() call shifts one value. */ - mockSummaryQueue: ContentBlock[][] = [] - /** If set, summarize() throws this error. */ - summarizeError: Error | null = null - - override estimateContentTokens(blocks: readonly ContentBlock[]): number { - if (this.summaryOutputs.has(blocks)) return blocks.length * 2 - if (this.estimateFramedSummariesCheaply && isFramedCheckpoint(blocks)) return blocks.length * 2 - // 10 tokens per block — predictable for retention/threshold math. - return blocks.length * 10 - } - - override async summarize(text: string, agent: Agent): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> { - const model = this.config.summarizationModel || agent.options.model || '' - this.summarizeCalls.push({ text, model }) - if (this.summarizeError) throw this.summarizeError - const summary = this.mockSummaryQueue.shift() ?? this.mockSummary - this.summaryOutputs.add(summary) - return { summary, model } - } -} - -function isFramedCheckpoint(blocks: readonly ContentBlock[]): boolean { - const first = blocks[0] - const last = blocks[blocks.length - 1] - return first?.type === 'text' - && first.text.includes('') - && last?.type === 'text' - && last.text === '' -} - -/** Create a test service with a throwaway context (auto disabled — no model). */ -function createTestService(overrides: Partial = {}): TestCompactService { - return new TestCompactService(new Context(), cfg({ auto: false, ...overrides })) -} - -/** 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')) - for (let t = 1; t <= turns; t++) { - s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: t, step: 1 }) - for (let m = 0; m < messagesPerTurn; m++) { - s.append('user/message', { - content: [{ type: 'text', text: `turn ${t} user message ${m + 1}.${LONG_FIXTURE_TEXT}` }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - s.append('assistant/message', { - turn: t, step: 1, - content: [{ type: 'text', text: `turn ${t} assistant response ${m + 1}.${LONG_FIXTURE_TEXT}` }], - }, { surfaceOp: 'append' }) - } - s.append('step/end', { turn: t, step: 1 }) - s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) - } - // Open one more turn so compaction's events are turn-enclosed, as they are - // when the loop runs the auto-compaction listener mid-turn. - if (leaveOpen) { - s.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - } - return s -} - -/** Build a session with tool calls for richer extraction tests. */ -function sessionWithTools(): Session { - const s = new Session(SessionId('tools')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('user/message', { - content: [{ type: 'text', text: 'read file x' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [ - { type: 'text', text: 'Let me read that file.' }, - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"cat x"}' }, - ], - }, { surfaceOp: 'append' }) - s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"cat x"}' }) - s.append('tool/result', { - turn: 1, step: 1, callId: CallId('c1'), - content: [{ type: 'text', text: 'hello world' }], - isError: false, - }, { surfaceOp: 'append' }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [{ type: 'text', text: 'The file contains: hello world' }], - }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step: 1 }) - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // Open a trailing turn so compaction's events are turn-enclosed (as they are - // when the loop runs the auto-compaction listener mid-turn). - s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - return s -} - -/** - * Build a session of `turns` turns, each a SINGLE step containing an - * assistant/message that issues a tool-call plus its tool/result — the real - * multi-node-step shape (a step is two surface nodes: the assistant and the - * result). Each turn is preceded by a user/message. Used to exercise - * step-alignment: a region boundary must not fall between the assistant and its - * result. - */ -function toolTurnSession(turns: number): Session { - const s = new Session(SessionId('tools-multi')) - for (let t = 1; t <= turns; t++) { - s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { - content: [{ type: 'text', text: `turn ${t} request` }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - s.append('step/start', { turn: t, step: 1 }) - s.append('assistant/message', { - turn: t, step: 1, - content: [ - { type: 'text', text: `turn ${t} calling tool` }, - { type: 'tool-call', id: CallId(`c${t}`), name: 'bash', arguments: '{"command":"ls"}' }, - ], - }, { surfaceOp: 'append' }) - s.append('tool/call', { turn: t, step: 1, callId: CallId(`c${t}`), name: 'bash', arguments: '{"command":"ls"}' }) - s.append('tool/result', { - turn: t, step: 1, callId: CallId(`c${t}`), - content: [{ type: 'text', text: `turn ${t} output` }], - isError: false, - }, { surfaceOp: 'append' }) - s.append('step/end', { turn: t, step: 1 }) - s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) - } - // Open a trailing turn so compaction's events are turn-enclosed. - s.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - return s -} - -/** - * Assert the derived transcript has NO orphaned tool-result: every - * `tool-result` block's `toolCallId` must be matched by a preceding `tool-call` - * block in an earlier (assistant) message. A dangling tool-result is exactly - * what splitting a step at compaction produces, and every provider rejects it. - */ -function expectNoOrphanToolResults(messages: Message[]): void { - const seenCallIds = new Set() - for (const msg of messages) { - for (const block of msg.content) { - if (block.type === 'tool-call') seenCallIds.add(block.id) - if (block.type === 'tool-result') { - expect(seenCallIds.has(block.toolCallId), - `orphaned tool-result for callId ${block.toolCallId} (no preceding tool-call)`).toBe(true) - } - } - } -} - -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 () => { - // 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) - - const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) - expect(result).not.toBeNull() - expect(result!.shadowedSeqs.length).toBeGreaterThan(0) - // No dangling tool-result: every compacted/retained step stayed whole. - expectNoOrphanToolResults(session.deriveMessages()) - // The most-recent step's result is retained verbatim (still on the surface). - const lastResultSeq = session.events.findLast(e => e.type === 'tool/result')!.seq - expect(result!.shadowedSeqs).not.toContain(lastResultSeq) - }) - - it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => { - // 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 }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], - }, { surfaceOp: 'append' }) - s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step: 1 }) - // Turn stays open. - - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) - const result = await compactIfNeeded(svc, s, '', 'm', SIGNAL) - expect(result).toBeNull() - expect(s.events.some(e => e.type === 'compact/start')).toBe(false) - }) - - it('compactRegion rejects a start that splits a step (unbalanced boundary)', async () => { - 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 - // 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')) - .rejects.toThrow(/start seq .* is not a balanced boundary/) - expect(userSeq).toBeLessThan(resultSeq) // sanity: ordering as expected - }) - - it('compactRegion rejects an end that splits a step (unbalanced boundary)', async () => { - const svc = createTestService() - const session = toolTurnSession(1) - const nodes = session.surface.nodes - const userSeq = nodes[0]!.seq - const asstSeq = nodes[1]!.seq - // 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')) - .rejects.toThrow(/end seq .* is not a balanced boundary/) - }) - - it('compactRegion rejects an end inside an open tail step', async () => { - const svc = createTestService() - const s = new Session(SessionId('open-tail')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('assistant/message', { - turn: 1, step: 1, - 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 - await expect(compactRegion(svc, s, userSeq, asstSeq, 'm')) - .rejects.toThrow(/end seq .* is not a balanced boundary/) - }) - - it('compactRegion accepts step-aligned boundaries (pre-step user → last result of a closed step)', async () => { - 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 result = await compactRegion(svc, session, startSeq, endSeq, 'm') - expect(result.shadowedRange).toEqual({ start: startSeq, end: endSeq }) - expectNoOrphanToolResults(session.deriveMessages()) - }) - - it('compactRegion accepts a single inter-step node (start === end on a pre-step user/message)', async () => { - 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 result = await compactRegion(svc, session, userSeq, userSeq, 'm') - expect(result.shadowedRange).toEqual({ start: userSeq, end: userSeq }) - }) - - it('compactRegion accepts an injection-turn context node (no step at all)', async () => { - const svc = createTestService() - const s = new Session(SessionId('inject')) - // An idle inject(): turn/start → context/message, NO step. A later turn is - // open so compaction's events are turn-enclosed. - s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } }) - s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - 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 result = await compactRegion(svc, s, ctxSeq, ctxSeq, 'm') - expect(result.shadowedRange).toEqual({ start: ctxSeq, end: ctxSeq }) - }) -}) - -describe('BasicCompactService.estimateEventTokens', () => { - it('returns 0 for non-message events (boundary, chunk, step/end, tool/call)', () => { - const svc = createTestService() - expect(svc.estimateEventTokens({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } })).toBe(0) - expect(svc.estimateEventTokens({ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } })).toBe(0) - expect(svc.estimateEventTokens({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } })).toBe(0) - expect(svc.estimateEventTokens({ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } })).toBe(0) - expect(svc.estimateEventTokens({ type: 'tool/call', seq: 4, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'read', arguments: '{}' } })).toBe(0) - }) - - it('returns estimate for message-producing events', () => { - const svc = createTestService() - const userEvent: SessionEvent = { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } } } - expect(svc.estimateEventTokens(userEvent)).toBe(10) - - const asstEvent: SessionEvent = { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] } } - expect(svc.estimateEventTokens(asstEvent)).toBe(20) - - const toolEvent: SessionEvent = { type: 'tool/result', seq: 2, time: 3, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'output' }], isError: false } } - expect(svc.estimateEventTokens(toolEvent)).toBe(10) - }) -}) - -describe('BasicCompactService.estimateTokens', () => { - it('sums token estimates across messages', () => { - const svc = createTestService() - const messages: Message[] = [ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'hi' }, { type: 'text', text: 'there' }] }, - ] - // 1 block * 10 + 4 (role) + 2 blocks * 10 + 4 (role) = 10 + 4 + 20 + 4 = 38 - expect(svc.estimateTokens(messages)).toBe(38) - }) - - it('includes system prompt in the estimate', () => { - const svc = createTestService() - const messages: Message[] = [ - { role: 'user', content: [{ type: 'text', text: 'hi' }] }, - ] - const systemPrompt = 'You are a helpful assistant.' - // 1 block * 10 + 4 (role) + ceil(28/4) = 10 + 4 + 7 = 21 - expect(svc.estimateTokens(messages, systemPrompt)).toBe(21) - }) -}) - -describe('BasicCompactService.compactRegion', () => { - it('shadows surface nodes and inserts a summary via user/message', async () => { - const svc = createTestService() - const session = multiTurnSession(3, 1) // 3 turns, 2 surface nodes each = 6 nodes - - const nodes = session.surface.nodes - expect(nodes.length).toBe(6) - - const firstSeq = nodes[0]!.seq - const secondSeq = nodes[1]!.seq - const result = await compactRegion(svc, session, firstSeq, secondSeq, 'test-model') - - expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq]) - expect(result.shadowedRange.start).toBe(firstSeq) - expect(result.shadowedRange.end).toBe(secondSeq) - expect(result.summary).toEqual(svc.mockSummary) - - const events = session.events - const startEvent = events.findLast(e => e.type === 'compact/start') - const summaryEvent = events.findLast(e => e.type === 'compact/summary') - const endEvent = events.findLast(e => e.type === 'compact/end') - expect(startEvent).toBeDefined() - expect(summaryEvent).toBeDefined() - expect(endEvent).toBeDefined() - // The provenance record carries the summarize call's envelope, so "which - // model wrote this summary" is answerable from the log alone. - expect(summaryEvent?.type === 'compact/summary' && summaryEvent.data.model).toBe('test-model') - - // compact/* events are log-only — no surfaceOp (type system enforces this). - const startRaw = startEvent as unknown as { surfaceOp?: unknown } - expect(startRaw.surfaceOp).toBeUndefined() - - // The user/message carries the replace surfaceOp. - const userMsg = events.findLast(e => e.type === 'user/message')! - const surfaceUserMsg = userMsg as SurfaceEvent - expect(surfaceUserMsg.surfaceOp).toEqual({ op: 'replace', start: firstSeq, end: secondSeq }) - expect(surfaceUserMsg.sourceEventSeqs).toContain(startEvent!.seq) - expect(surfaceUserMsg.sourceEventSeqs).toContain(summaryEvent!.seq) - expect(surfaceUserMsg.sourceEventSeqs).toContain(firstSeq) - expect(surfaceUserMsg.sourceEventSeqs).toContain(secondSeq) - // compact/end is appended AFTER the replacement (the lock brackets the whole - // op), so the replacement cannot reference it — sourceEventSeqs may only - // reference earlier seqs. - expect(surfaceUserMsg.sourceEventSeqs).not.toContain(endEvent!.seq) - expect(endEvent!.seq).toBeGreaterThan(userMsg.seq) - - // 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) - - // deriveMessages() produces the framed summary as a user-role message: - // a checkpoint preamble + tag-wrapped summary blocks. - const derived = session.deriveMessages() - expect(derived.length).toBe(5) - expect(derived[0]!.role).toBe('user') - const framed = derived[0]!.content - expect(framed[0]).toMatchObject({ type: 'text' }) - expect((framed[0] as { text: string }).text).toContain('') - expect(framed).toContainEqual(svc.mockSummary[0]) - expect((framed[framed.length - 1] as { text: string }).text).toBe('') - }) - - it('throws when start or end are not surface nodes', async () => { - const svc = createTestService() - const session = multiTurnSession(1, 1) - await expect(compactRegion(svc, session, 999, 1000, 'm')) - .rejects.toThrow(/start seq 999 not found in surface/) - }) - - it('throws when start is positioned after end on the surface', async () => { - 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')) - .rejects.toThrow(/is after end seq .* on the surface/) - }) - - it('throws when compaction is already in progress', async () => { - const svc = createTestService() - 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')) - .rejects.toThrow(/compaction already in progress/) - }) - - it('appends compact/end with error on summarize failure', async () => { - const svc = createTestService() - svc.summarizeError = new Error('model unavailable') - const session = multiTurnSession(2, 1) - const nodes = session.surface.nodes - - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) - .rejects.toThrow('model unavailable') - - const endEvent = session.events.findLast(e => e.type === 'compact/end') - expect(endEvent).toBeDefined() - // multiTurnSession(2,…) closes turns 1-2 and leaves turn 3 open; compaction - // stamps the open turn. - expect(endEvent!.data).toMatchObject({ turn: 3, error: 'model unavailable' }) - - // No replace-op user/message was appended (summarize failed). - const userMsgsAfter = session.events.filter(e => e.type === 'user/message') - const replaceMsgs = userMsgsAfter.filter((e) => { - const se = e as unknown as { surfaceOp?: unknown } - return se.surfaceOp !== undefined && typeof se.surfaceOp !== 'string' - }) - expect(replaceMsgs.length).toBe(0) - }) - - it('extracts conversation text for summarization', async () => { - const svc = createTestService() - const session = multiTurnSession(1, 2) - const nodes = session.surface.nodes - - await compactRegion(svc, session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - - expect(svc.summarizeCalls.length).toBe(1) - const { text, model } = svc.summarizeCalls[0]! - expect(model).toBe('m') - expect(text).toContain('User: turn 1 user message 1') - expect(text).toContain('Assistant: turn 1 assistant response 1') - }) - - it('frames the landed summary with a checkpoint preamble and tags, keeping raw provenance', async () => { - const svc = createTestService() - svc.mockSummary = [{ type: 'text', text: 'STRUCTURED SUMMARY' }] - const session = multiTurnSession(3, 1) - const nodes = session.surface.nodes - - const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') - - // Provenance (compact/summary) carries the RAW, unframed summary. - expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }]) - const summaryEvent = session.events.findLast(e => e.type === 'compact/summary')! - expect(summaryEvent.data).toMatchObject({ summary: [{ type: 'text', text: 'STRUCTURED SUMMARY' }] }) - - // The landed surface node is framed: preamble + tag-wrapped summary. - const landed = session.deriveMessages()[0]!.content - expect((landed[0] as { text: string }).text).toContain('checkpoint') - expect((landed[0] as { text: string }).text).toContain('') - expect(landed).toContainEqual({ type: 'text', text: 'STRUCTURED SUMMARY' }) - expect((landed[landed.length - 1] as { text: string }).text).toBe('') - }) - - it('extracts tool-call and tool-result context', async () => { - const svc = createTestService() - const session = sessionWithTools() - const nodes = session.surface.nodes - - const firstSeq = nodes[0]!.seq - const lastSeq = nodes[nodes.length - 1]!.seq - await compactRegion(svc, session, firstSeq, lastSeq, 'm') - - expect(svc.summarizeCalls.length).toBe(1) - const { text } = svc.summarizeCalls[0]! - expect(text).toContain('read file x') - expect(text).toContain('bash') - expect(text).toContain('Tool result') - }) -}) - -describe('BasicCompactService.compactIfNeeded', () => { - it('returns null when tokens are under threshold', async () => { - const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 }) - const session = multiTurnSession(1, 1) - expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() - }) - - it('compacts when tokens exceed threshold', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) - const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60 - - const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) - expect(result).not.toBeNull() - expect(result!.shadowedSeqs.length).toBeGreaterThan(0) - }) - - it('counts the session prefix toward pressure (every request carries it in front of the history)', async () => { - const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 10 }) - const session = multiTurnSession(3, 1) // 6 derived messages ≈ 84 estimated tokens — under the 100 threshold alone - expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() - - // The loop composes the agent/session-prefix product before the pre-step - // seam and hands it to the gate; it rides every request, so pressure must - // include it — the same history now crosses the threshold. - const sessionPrefix: Message[] = [ - { role: 'user', content: [{ type: 'text', text: `opener one.${LONG_FIXTURE_TEXT}` }] }, - { role: 'user', content: [{ type: 'text', text: `opener two.${LONG_FIXTURE_TEXT}` }] }, - ] - const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL, sessionPrefix) - expect(result).not.toBeNull() - // The prefix itself is NOT history: compaction shadowed surface nodes only. - expect(sessionPrefix).toHaveLength(2) - }) - - it('returns the first compaction result when a zero-retry pass converges after the loop', async () => { - // With compactionRetries=0 there is no next-loop threshold check after the - // first mutation, so the success path is the post-loop `return result`. - const svc = createTestService({ - contextWindow: 100, - thresholdRatio: 0.7, - retainTokens: 10, - compactionRetries: 0, - }) - const session = multiTurnSession(3, 1) // 6 derived messages = 84 estimated tokens. - - const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) - - expect(result).not.toBeNull() - expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(1) - expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(70) - }) - - it('walks tail→head and retains nodes within token budget', async () => { - const svc = createTestService({ contextWindow: 350, thresholdRatio: 0.2, retainTokens: 15 }) - const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens - - const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) - 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) - }) - - it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { - // 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 () => { - // 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]. - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'do a big multi-step task' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - for (let step = 1; step <= 5; step++) { - s.append('step/start', { turn: 1, step }) - s.append('assistant/message', { - turn: 1, step, - content: [{ type: 'text', text: `step ${step}` }, { type: 'tool-call', id: CallId(`c${step}`), name: 'bash', arguments: '{}' }], - }, { surfaceOp: 'append' }) - s.append('tool/call', { turn: 1, step, callId: CallId(`c${step}`), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step, callId: CallId(`c${step}`), content: [{ type: 'text', text: `out ${step}` }], isError: false }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step }) - } - // The turn stays OPEN (no turn/end) — the model is mid-turn, about to run - // step 6. Surface: user + 5×[asst, result] = 11 nodes. - const nodesBefore = s.surface.nodes.length - expect(nodesBefore).toBe(11) - - const result = await compactIfNeeded(svc, s, '', 'm', SIGNAL) - expect(result).not.toBeNull() - // Early steps of the SAME open turn were shadowed (impossible under layer 2). - expect(result!.shadowedSeqs.length).toBeGreaterThan(0) - // 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) - // No orphaned tool-result survives (whole-step boundaries respected). - expectNoOrphanToolResults(s.deriveMessages()) - }) - - it('returns null for an empty surface', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) - const session = new Session(SessionId('empty')) - expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() - }) - - it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => { - // 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) - - 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 turn5StartSeq = s.events.filter(e => e.type === 'turn/start').at(-1)!.seq - expect(summaryHeadSeq).toBeGreaterThan(turn5StartSeq) - - // Append a verbatim node in the open turn (a step's output), still over - // threshold, then compact again — the older summary + closed turns compact, - // the fresh nodes are retained. - s.append('step/start', { turn: 5, step: 1 }) - s.append('user/message', { content: [{ type: 'text', text: 'turn 5 work' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 5, step: 1 }) - - const second = await compactIfNeeded(svc, s, '', 'm', SIGNAL) - expect(second).not.toBeNull() - expect(second!.shadowedSeqs.length).toBeGreaterThan(0) - // The fresh open-turn nodes were NOT compacted. - const turn5UserSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'turn 5 work'))!.seq - expect(second!.shadowedSeqs).not.toContain(turn5UserSeq) - }) - - it('re-compacts smaller summaries until the post-compaction surface drops below threshold', async () => { - const svc = createTestService({ - contextWindow: 100, - thresholdRatio: 0.5, - retainTokens: 10, - compactionRetries: 2, - }) - svc.estimateFramedSummariesCheaply = false - svc.mockSummaryQueue = [ - Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), - [{ type: 'text', text: 'second' }], - ] - const session = multiTurnSession(4, 1) - - const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) - - expect(result).not.toBeNull() - expect(svc.summarizeCalls).toHaveLength(2) - expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(2) - expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(50) - }) - - it('throws after the configured re-compaction attempts still leave the surface above threshold', async () => { - const svc = createTestService({ - contextWindow: 100, - thresholdRatio: 0.5, - retainTokens: 10, - compactionRetries: 1, - }) - svc.estimateFramedSummariesCheaply = false - svc.mockSummaryQueue = [ - Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), - Array.from({ length: 3 }, (_, index) => ({ type: 'text', text: `second ${index}` })), - ] - const session = multiTurnSession(4, 1) - - await expect(compactIfNeeded(svc, session, '', 'm', SIGNAL)) - .rejects.toThrow(/still above threshold after 2 compaction attempts/) - expect(svc.summarizeCalls).toHaveLength(2) - }) -}) - -describe('BasicCompactService replay equivalence', () => { - it('produces identical deriveMessages() after seeding from compacted log', async () => { - const svc = createTestService() - const session = multiTurnSession(3, 1) - const nodes = session.surface.nodes - - await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') - const derived = session.deriveMessages() - - const replayed = new Session(SessionId('replay'), [...session.events]) - expect(replayed.deriveMessages()).toEqual(derived) - }) -}) - -describe('BasicCompactService blocking (compaction in progress)', () => { - it('detects in-progress compaction from unmatched compact/start', async () => { - const svc = createTestService() - const session = multiTurnSession(1, 1) - session.append('compact/start', { turn: 1 }) - 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')) - .rejects.toThrow(/compaction already in progress/) - }) - - it('allows compaction after compact/end is appended', async () => { - const svc = createTestService() - const session = multiTurnSession(2, 1) - 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') - expect(result).toBeDefined() - }) - - it('is not wedged by an orphaned compact/start from a prior (now-closed) turn', async () => { - // 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' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('user/message', { content: [{ type: 'text', text: 'turn 1' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply 1' }] }, { surfaceOp: 'append' }) - s.append('compact/start', { turn: 1 }) // ← orphaned: no matching compact/end - s.append('step/end', { turn: 1, step: 1 }) - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // repair closed the turn - // A new open turn. - s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - 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') - expect(result).toBeDefined() - }) -}) - -describe('BasicCompactService token estimation (char/4 heuristic)', () => { - it('estimates text blocks with char/4 + overhead', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - // 'this is a somewhat longer text block' = 36 → ceil(36/4)+4 = 13; 'short' = 5 → 2+4 = 6 - const blocks: ContentBlock[] = [ - { type: 'text', text: 'this is a somewhat longer text block' }, - { type: 'text', text: 'short' }, - ] - expect(svc.estimateContentTokens(blocks)).toBe(19) - }) - - it('estimates reasoning blocks same as text', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - // 'thinking about this...' = 22 → ceil(22/4)+4 = 10 - expect(svc.estimateContentTokens([{ type: 'reasoning', text: 'thinking about this...' }])).toBe(10) - }) - - it('estimates tool-call blocks from name + arguments', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - // 'bash' = 4 → 1; '{"command":"ls"}' = 16 → 4; + 4 overhead = 9 - expect(svc.estimateContentTokens([ - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }, - ])).toBe(9) - }) - - it('estimates tool-result blocks recursively', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - // inner text 5 → 2+4 = 6; outer 6 + 4 overhead = 10 - expect(svc.estimateContentTokens([ - { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'hello' }], isError: false }, - ])).toBe(10) - }) - - it('returns 0 for empty content blocks', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - expect(svc.estimateContentTokens([])).toBe(0) - }) - - it('honors a configured charsPerToken (fractional densities included)', () => { - // 'this is a somewhat longer text block' = 36 chars. - const blocks: ContentBlock[] = [{ type: 'text', text: 'this is a somewhat longer text block' }] - // charsPerToken 2: ceil(36/2)+4 = 22 — a CJK-density config doubles the estimate. - const dense = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 })) - expect(dense.estimateContentTokens(blocks)).toBe(22) - // Fractional density is legal: ceil(36/1.5)+4 = 28. - const fractional = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 })) - expect(fractional.estimateContentTokens(blocks)).toBe(28) - // The system-prompt term scales with the same knob: 36-char prompt at density 2 → ceil(36/2) = 18. - expect(dense.estimateTokens([], 'this is a somewhat longer text block')).toBe(18) - }) -}) - -describe('BasicCompactService HMR safety', () => { - it('registers as ctx.compact', () => { - const ctx = new Context() - void new BasicCompactService(ctx, cfg({ auto: false })) - expect(ctx.compact).toBeDefined() - expect(ctx.compact).toBeInstanceOf(BasicCompactService) - }) - - 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. - const ctx = new Context() - await ctx.plugin(LlmService) - const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false })) - expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) - - await fiber.dispose() - expect(ctx.get('compact')).toBeUndefined() - }) -}) - -describe('BasicCompactService config validation', () => { - it('rejects invalid numeric config values', () => { - expect(() => new BasicCompactService(new Context(), cfg({ auto: false, contextWindow: 0 }))) - .toThrow(/contextWindow .* positive integer/) - expect(() => new BasicCompactService(new Context(), cfg({ auto: false, thresholdRatio: 0 }))).toThrow(/thresholdRatio .* \(0, 1\]/) - expect(() => new BasicCompactService(new Context(), cfg({ auto: false, thresholdRatio: 1.1 }))).toThrow(/thresholdRatio .* \(0, 1\]/) - expect(() => new BasicCompactService(new Context(), cfg({ auto: false, retainTokens: -1 }))) - .toThrow(/retainTokens .* non-negative integer/) - expect(() => new BasicCompactService(new Context(), cfg({ auto: false, maxTokens: 0 }))).toThrow(/maxTokens .* positive integer/) - expect(() => new BasicCompactService(new Context(), cfg({ auto: false, compactionRetries: -1 }))) - .toThrow(/compactionRetries .* non-negative integer/) - expect(() => new BasicCompactService( - new Context(), cfg({ auto: false, summarizationModel: 1 } as unknown as Partial), - )).toThrow(/summarizationModel must be a string/) - expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial))) - .toThrow(/auto must be a boolean/) - expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 0 }))) - .toThrow(/charsPerToken .* positive finite number/) - expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: Number.NaN }))) - .toThrow(/charsPerToken .* positive finite number/) - }) - - it('accepts a large retain budget because convergence is enforced dynamically', () => { - expect(() => new BasicCompactService(new Context(), cfg({ - auto: false, - contextWindow: 1000, - thresholdRatio: 0.5, - retainTokens: 900, - }))).not.toThrow() - }) - - it('the default config is valid', () => { - expect(() => new BasicCompactService(new Context(), cfg({ auto: false }))).not.toThrow() - }) -}) - -/** An adapter that emits a fixed summary text, for exercising the real summarize() path. */ -class ScriptedAdapter extends LlmAdapter { - lastOptions: GenerateOptions | null = null - constructor(private summaryText: string) { - super() - } - - async * stream(options: GenerateOptions): AsyncIterable { - this.lastOptions = options - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text: this.summaryText } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -/** An adapter that emits arbitrary content blocks, preserving reasoning/text shape. */ -class BlocksAdapter extends LlmAdapter { - lastOptions: GenerateOptions | null = null - constructor(private blocks: readonly ContentBlock[]) { - super() - } - - async * stream(options: GenerateOptions): AsyncIterable { - this.lastOptions = options - for (const [index, block] of this.blocks.entries()) { - yield { type: 'block-start', index, blockType: block.type } - switch (block.type) { - case 'text': - yield { type: 'text-delta', index, text: block.text } - break - case 'reasoning': - yield { type: 'reasoning-delta', index, text: block.text } - break - default: - yield { type: 'block-end', index, block } - } - } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -/** Wire a real LlmService + arbitrary-block adapter into a context. */ -async function ctxWithBlocks(blocks: readonly ContentBlock[], model = 'test-model'): Promise<{ ctx: Context; adapter: BlocksAdapter }> { +function createContext( + models: Record = { + [MODEL]: { contextWindow: 100, charsPerToken: 1_000 }, + }, +): Context { const ctx = new Context() - await ctx.plugin(LlmService) - const adapter = new BlocksAdapter(blocks) - ctx.llm.registerAdapter([model], adapter) - return { ctx, adapter } -} - -/** Wire a real LlmService + scripted adapter into a context. */ -async function ctxWithModel(summaryText: string, model = 'test-model'): Promise<{ ctx: Context; adapter: ScriptedAdapter }> { - const ctx = new Context() - await ctx.plugin(LlmService) - const adapter = new ScriptedAdapter(summaryText) - ctx.llm.registerAdapter([model], adapter) - return { ctx, adapter } -} - -/** An adapter whose stream ends with a finish chunk of the given reason (no content). */ -class FinishOnlyAdapter extends LlmAdapter { - constructor(private reason: StreamChunk & { type: 'finish' }) { - super() - } - - async * stream(): AsyncIterable { - yield this.reason - } -} - -/** Wire a real LlmService + finish-only adapter into a context. */ -async function ctxWithFinish(reason: (StreamChunk & { type: 'finish' })['reason'], model = 'test-model'): Promise { - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter([model], new FinishOnlyAdapter({ type: 'finish', reason })) + void new TokenMeterService(ctx, { models }) return ctx } -/** A minimal Agent stub carrying just session + options (enough for the listeners). */ -function stubAgent(session: Session, model?: string): Agent { - return { session, options: { model } } as unknown as Agent +function agent(session: Session, model?: string): Agent { + return { session, options: model === undefined ? {} : { model } } as Agent } -function compactIfNeeded( - svc: BasicCompactService, - session: Session, - fullSystemPrompt: string, - model: string, - signal: AbortSignal, - sessionPrefix: readonly Message[] = [], -) { - return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, sessionPrefix, signal) -} - -function compactRegion( - svc: BasicCompactService, - session: Session, - start: number, - end: number, - model: string, - signal?: AbortSignal, -) { - return svc.compactRegion(session, start, end, stubAgent(session, model), signal) -} - -function summarize(svc: BasicCompactService, text: string, model: string) { - return svc.summarize(text, stubAgent(new Session(SessionId('summary')), model)) -} - -describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { - it('summarizes via the registered adapter and returns its content', async () => { - const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, cfg({ auto: false, maxTokens: 512 })) - - const { summary, model, maxTokens } = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') - expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) - // The returned envelope reports what the call actually used — the caller - // logs it on compact/summary (the reconstructability RFC). - expect(model).toBe('test-model') - expect(maxTokens).toBe(512) - // The fixed system prompt and maxTokens flow through. - expect(adapter.lastOptions!.system).toContain('compaction engine') - expect(adapter.lastOptions!.system).toContain('## Next Step') - expect(adapter.lastOptions!.maxTokens).toBe(512) - expect(adapter.lastOptions!.sessionId).toBe(SessionId('summary')) - expect(adapter.lastOptions!.messages[0]!.content[0]).toMatchObject({ type: 'text' }) - }) - - it('uses maxTokens as the summarization provider cap', async () => { - const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, cfg({ - auto: false, - maxTokens: 50, - })) - - await summarize(svc, 'User: hi', 'test-model') - - expect(adapter.lastOptions!.maxTokens).toBe(50) - }) - - it('keeps only text blocks in the stored summary (drops reasoning and tool-call)', async () => { - const { ctx } = await ctxWithBlocks([ - { type: 'reasoning', text: 'private chain of thought' }, - { type: 'text', text: 'PUBLIC SUMMARY' }, - // A model reply can carry a tool-call; it must not survive into the - // synthesized user/message summary as an orphaned call. - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, - ]) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - - const { summary } = await summarize(svc, 'User: hi', 'test-model') - - expect(summary).toEqual([{ type: 'text', text: 'PUBLIC SUMMARY' }]) - }) - - it('throws when no text block remains after filtering', async () => { - const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }]) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - - await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no text summary content/) - }) - - it('throws when no model is provided', async () => { - const { ctx } = await ctxWithModel('x') - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/) - }) - - it('rethrows when the stream ends with a finish-error chunk', async () => { - const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) - }) - - it('rethrows a finish-error chunk without a code (code stays undefined)', async () => { - const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - const error = await summarize(svc, 'text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) - expect(error?.message).toBe('opaque failure') - expect(error?.code).toBeUndefined() - }) - - it('rethrows when the stream ends with a finish-aborted chunk', async () => { - const ctx = await ctxWithFinish({ kind: 'aborted' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) - }) - - it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => { - const ctx = await ctxWithFinish({ kind: 'max-tokens' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) - }) - - it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => { - const ctx = await ctxWithFinish({ kind: 'max-tokens' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - const session = multiTurnSession(2, 1) - const before = [...session.surface.nodes] - const nodes = session.surface.nodes - - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')) - .rejects.toMatchObject({ code: 'MAX_TOKENS' }) - - // No replacement landed — the surface is byte-identical, and the lock was - // released with the error (compact/end carries it). - expect(session.surface.nodes).toEqual(before) - const endEvent = session.events.findLast(e => e.type === 'compact/end')! - const endData = endEvent.data as { error?: string } - expect(endData.error).toContain('truncated') - }) - - it('compactRegion uses the real summarizer end-to-end', async () => { - const { ctx } = await ctxWithModel('CONDENSED') - const svc = new BasicCompactService(ctx, cfg({ auto: false })) - const session = multiTurnSession(2, 1) - const nodes = session.surface.nodes - - const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, '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' }) - }) - - it('rejects a summary that is not smaller than the shadowed content', async () => { - const svc = createTestService({ auto: false }) - const session = multiTurnSession(2, 1) - 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')) - .rejects.toThrow(/summary is not smaller than the shadowed content/) - expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) - }) - - it('rejects when the framed checkpoint is not smaller than the shadowed content', async () => { - const svc = createTestService({ auto: false }) - svc.estimateFramedSummariesCheaply = false - const session = new Session(SessionId('framed-nonshrinking')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: 'tiny user' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'tiny assistant' }] }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - const before = [...session.surface.nodes] - const nodes = session.surface.nodes - - await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, '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) - }) -}) - -describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { - /** Fire the agent/pre-step serial checkpoint as the loop does. */ - function firePreStep(ctx: Context, agent: Agent, step: number, fullSystemPrompt: string): Promise { - return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, [], SIGNAL) - } - - it('compacts (mutating the surface) when over threshold', async () => { - const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })) - const session = multiTurnSession(5, 1) // 10 surface nodes - const agent = stubAgent(session, 'test-model') - const before = session.surface.nodes.length - - await firePreStep(ctx, agent, 1, '') - - // The surface shrank in place, and a summary checkpoint landed. - expect(session.surface.nodes.length).toBeLessThan(before) - expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) - // The re-derived head message is the framed summary checkpoint. - expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) - }) - - it('logs compaction details when auto-compaction returns a converged result', async () => { - const ctx = new Context() - const infos: string[] = [] - ctx.logger.info = ((msg: string) => void infos.push(msg)) as typeof ctx.logger.info - void new TestCompactService(ctx, cfg({ - contextWindow: 100, - thresholdRatio: 0.7, - retainTokens: 10, - compactionRetries: 0, - })) - const session = multiTurnSession(3, 1) - const agent = stubAgent(session, 'test-model') - - await firePreStep(ctx, agent, 1, '') - - expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(1) - expect(infos.some(msg => msg.includes('compaction: shadowed'))).toBe(true) - expect(infos.some(msg => msg.includes('estimated tokens after compaction'))).toBe(true) - }) - - it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { - const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, cfg({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })) - const session = multiTurnSession(3, 1) // over the 0.5 threshold - const agent = stubAgent(session, 'test-model') - - // A step-2 checkpoint (a tool-heavy turn's later step) must still compact — - // the surface accumulated assistant/message + tool/result nodes since step 1. - await firePreStep(ctx, agent, 2, '') - expect(session.events.some(e => e.type === 'compact/start')).toBe(true) - }) - - it('does nothing when under threshold', async () => { - const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, cfg({ contextWindow: 128000, thresholdRatio: 0.8 })) - const session = multiTurnSession(1, 1) - const agent = stubAgent(session, 'test-model') - - await firePreStep(ctx, agent, 1, '') - expect(session.events.some(e => e.type === 'compact/start')).toBe(false) - }) - - it('leaves the surface intact when compaction fails (summarize rejects)', async () => { - // No adapter registered for this model → summarize() rejects → caught, the - // surface is untouched (the loop derives the full history). - const ctx = new Context() - await ctx.plugin(LlmService) - void new BasicCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 })) - const session = multiTurnSession(3, 1) - const agent = stubAgent(session, 'missing-model') - const before = session.surface.nodes.length - - await firePreStep(ctx, agent, 1, '') - // No summary landed; the surface is unchanged. - expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) - expect(session.surface.nodes.length).toBe(before) - }) - - it('does not register the listener when auto is false', async () => { - const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, cfg({ auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 })) - const session = multiTurnSession(3, 1) - const agent = stubAgent(session, 'test-model') - - await firePreStep(ctx, agent, 1, '') - expect(session.events.some(e => e.type === 'compact/start')).toBe(false) - }) - - it('summarization is interceptable at llm/stream (model routing for direct calls)', async () => { - const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model') - // 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() - }) - void new BasicCompactService(ctx, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })) - const session = multiTurnSession(5, 1) - const agent = stubAgent(session, 'agent-model') - - await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL) - - expect(adapter.lastOptions?.model).toBe('routed-model') - expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) - expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'ROUTED SUMMARY' }) - }) - - it('removes the auto pre-step listener when the plugin fiber is disposed', async () => { - const { ctx } = await ctxWithModel('SUMMARY') - const fiber = await ctx.plugin(BasicCompactService, cfg({ - contextWindow: 200, - thresholdRatio: 0.5, - retainTokens: 20, - })) - const session = multiTurnSession(5, 1) - const agent = stubAgent(session, 'test-model') - - await fiber.dispose() - await firePreStep(ctx, agent, 1, '') - - expect(session.events.some(e => e.type === 'compact/start')).toBe(false) - expect(ctx.get('compact')).toBeUndefined() - }) -}) - -describe('BasicCompactService transcript rendering (delegated to dsh-compact)', () => { - it('renders reasoning, context, and steering messages', async () => { - const svc = createTestService() - const s = new Session(SessionId('rich')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('context/message', { - content: [{ type: 'text', text: 'project context here' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [{ type: 'reasoning', text: 'thinking hard' }, { type: 'text', text: 'answer' }], - }, { surfaceOp: 'append' }) - s.append('steering/message', { - turn: 1, - content: [{ type: 'text', text: 'steer this way' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step: 1 }) - 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 - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - - const { text } = svc.summarizeCalls[0]! - expect(text).toContain('[Context: project context here]') - expect(text).toContain('[reasoning: thinking hard]') - expect(text).toContain('[Steering: steer this way]') - }) - - it('labels tool errors distinctly from tool results', async () => { - const svc = createTestService() - const s = new Session(SessionId('toolerr')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('user/message', { content: [{ type: 'text', text: 'run it' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { - turn: 1, step: 1, - content: [{ type: 'tool-call', id: CallId('c9'), name: 'bash', arguments: '{}' }], - }, { surfaceOp: 'append' }) - s.append('tool/call', { turn: 1, step: 1, callId: CallId('c9'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { - turn: 1, step: 1, callId: CallId('c9'), - content: [{ type: 'text', text: 'boom failure' }], - isError: true, - }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step: 1 }) - 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 - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - expect(svc.summarizeCalls[0]!.text).toContain('Tool error (call c9): boom failure') - }) -}) - -describe('BasicCompactService edge cases', () => { - it('renders bare and nested tool-result placeholders and unknown blocks', async () => { - const svc = createTestService() - const s = new Session(SessionId('toolresult')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - // assistant/message carrying a nested tool-result block, an unknown block, - // and the tool-call that the following tool/result answers (so the surface - // is tool-pairing balanced). - s.append('assistant/message', { - turn: 1, step: 1, - content: [ - { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'chart', data: 'x' } as unknown as ContentBlock] }, - { type: 'custom-widget', payload: 'x' } as unknown as ContentBlock, - { type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' }, - ], - }, { surfaceOp: 'append' }) - // tool/result whose content is itself only non-text → bare '[tool-result]'. - s.append('tool/call', { turn: 1, step: 1, callId: CallId('b1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { - turn: 1, step: 1, callId: CallId('b1'), - content: [{ type: 'tool-result', toolCallId: CallId('inner'), content: [] }], - isError: false, - }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step: 1 }) - 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 - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, '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 - expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder - }) - - it('estimates unknown block types via JSON length (default branch)', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - // A block whose type is none of the known kinds — exercises the default arm. - const unknown = { type: 'custom-widget', payload: 'some data' } as unknown as ContentBlock - expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) - }) - - it('auto-compaction reports bounded retry exhaustion after committing a smaller summary', async () => { - const { ctx } = await ctxWithModel('SUMMARY') - const warnings: string[] = [] - ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - void new BasicCompactService(ctx, cfg({ - contextWindow: 300, - thresholdRatio: 0.1, - retainTokens: 5, - compactionRetries: 0, - })) - const session = multiTurnSession(4, 1) - const agent = stubAgent(session, 'test-model') - - await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL) - expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) - // The surface was mutated; the head message is the framed summary checkpoint. - expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) - expect(warnings.some(w => w.includes('still above threshold after 1 compaction attempts'))).toBe(true) - }) - - it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => { - const svc = createTestService() - // A session whose only turn has CLOSED — scanning back from the tail hits - // turn/end before any turn/start, so there is no open turn to enclose - // compaction's compact/* + replacement events, which the log contract forbids. - const s = new Session(SessionId('noturn')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step: 1 }) - 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')) - .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) - }) - - it('rejects compaction on a session with no turn boundaries at all', async () => { - const svc = createTestService() - // No turn events whatsoever — the open-turn scan falls through to the end - // of the log and finds none, so compaction is rejected (its events have no - // turn to enclose them). - const s = new Session(SessionId('turnless')) - 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')) - .rejects.toThrow(/no open turn/) - expect(s.events.some(e => e.type === 'compact/start')).toBe(false) - }) - - it('compactIfNeeded returns null for empty surface even when over threshold', async () => { - const svc = createTestService({ contextWindow: 1000, thresholdRatio: 0.1, retainTokens: 5 }) - const session = new Session(SessionId('empty-but-pressured')) - // No surface nodes, but a large system prompt pushes the estimate over threshold. - const bigPrompt = 'x'.repeat(800) // ceil(800/4) = 200 tokens >> threshold 100 - expect(await compactIfNeeded(svc, session, bigPrompt, 'm', SIGNAL)).toBeNull() - }) - - it('compactRegion throws when end is not a surface node (start valid)', async () => { - const svc = createTestService() - const session = multiTurnSession(1, 1) - const nodes = session.surface.nodes - await expect(compactRegion(svc, session, nodes[0]!.seq, 9999, 'm')) - .rejects.toThrow(/end seq 9999 not found in surface/) - }) - - it('compactRegion stringifies a non-Error thrown by summarize', async () => { - const svc = createTestService() - // Throw a non-Error value to exercise the String(error) branch in the catch. - svc.summarizeError = 'plain string failure' as unknown as Error - const session = multiTurnSession(1, 1) - 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') - const endEvent = session.events.findLast(e => e.type === 'compact/end')! - expect(endEvent.data).toMatchObject({ error: 'plain string failure' }) - }) - - it('auto-compaction listener stringifies a non-Error and proceeds', async () => { - const { ctx } = await ctxWithModel('SUMMARY') - const warnings: string[] = [] - ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - const svc = new TestCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 })) - svc.summarizeError = 'boom' as unknown as Error - const session = multiTurnSession(3, 1) - const agent = stubAgent(session, 'test-model') - const before = session.surface.nodes.length - - await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL) - // The failure was swallowed; the surface is untouched and a warning logged. - expect(session.surface.nodes.length).toBe(before) - expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) - expect(warnings.some(w => w.includes('compaction failed: boom'))).toBe(true) - }) - - it('auto-compaction listener takes the result-null branch (nothing to compact)', async () => { - const { ctx } = await ctxWithModel('SUMMARY') - // A large system prompt pushes the listener's estimate over threshold, but - // retainTokens is huge so compactIfNeeded walks everything and returns null. - // threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200. - const svc = new TestCompactService(ctx, cfg({ contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150 })) - const session = multiTurnSession(2, 1) - const agent = stubAgent(session, 'test-model') - const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 - - await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, [], SIGNAL) - expect(session.events.some(e => e.type === 'compact/start')).toBe(false) - expect(svc.summarizeCalls.length).toBe(0) - }) - - it('skips messages whose extracted text is empty across all kinds', async () => { - const svc = createTestService() - const s = new Session(SessionId('empties')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - 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 }) - // 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, - content: [{ type: 'tool-call', id: CallId('z1'), name: 'bash', arguments: '{}' }], - }, { surfaceOp: 'append' }) - s.append('tool/call', { turn: 1, step: 2, callId: CallId('z1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step: 2, callId: CallId('z1'), content: [], isError: false }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step: 2 }) - 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 - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - expect(svc.summarizeCalls[0]!.text).toBe('Assistant: [tool-call: bash({})]') - }) - - it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => { - const svc = createTestService() - const s = new Session(SessionId('placeholders')) - // A plugin-added block type (merge-extensible ContentBlockMap) — the - // placeholder path must cover every message kind, not just assistant. - const chart = (id: string): ContentBlock => ({ type: 'chart', data: id } as unknown as ContentBlock) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: 1, step: 1 }) - // user/message with only a plugin-added block → '[chart]' placeholder. - s.append('user/message', { content: [chart('y')], source: { kind: 'user' } }, { surfaceOp: 'append' }) - // assistant/message with a plugin-added block AND the tool-call its - // tool/result answers (so the surface is tool-pairing balanced). - s.append('assistant/message', { - turn: 1, step: 1, - content: [ - chart('z'), - { type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' }, - ], - }, { surfaceOp: 'append' }) - // tool/result with a plugin-added block → '[chart]' placeholder. - s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [chart('r')], isError: false }, { surfaceOp: 'append' }) - // context/message and steering/message with plugin-added content. - s.append('context/message', { content: [chart('c')], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('steering/message', { turn: 1, content: [chart('s')], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('step/end', { turn: 1, step: 1 }) - 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 - await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - const { text } = svc.summarizeCalls[0]! - // Every non-text block surfaces as a placeholder rather than being dropped. - expect(text).toContain('User: [chart]') - expect(text).toContain('Assistant: [chart]') - expect(text).toContain('Tool result (call e1): [chart]') - expect(text).toContain('[Context: [chart]]') - expect(text).toContain('[Steering: [chart]]') - }) - -}) - -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 () => { - // Replacement makes surface seqs non-monotonic. The next region is a - // positional span even when startSeq > endSeq. - const svc = createTestService({ auto: false }) - const session = multiTurnSession(4, 1) - - // A replacement puts its high-seq summary at the surface head. - const nodes0 = session.surface.nodes - const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm') - - const nodes1 = session.surface.nodes - expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq) - expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq) - - const startSeq = nodes1[0]!.seq - const endSeq = nodes1[2]!.seq - expect(startSeq).toBeGreaterThan(endSeq) - const second = await compactRegion(svc, session, startSeq, endSeq, 'm') - - // Selection follows surface positions, not sequence-number order. - expect(second.shadowedSeqs).toEqual([nodes1[0]!.seq, nodes1[1]!.seq, nodes1[2]!.seq]) - const finalNodes = session.surface.nodes - expect(finalNodes[0]!.seq).toBeGreaterThanOrEqual(second.summarySeq) - expect(session.deriveMessages().length).toBe(finalNodes.length) - }) - - it('extracts the second-compaction transcript in surface order, not log-seq order', async () => { - const svc = createTestService({ auto: false }) - const session = multiTurnSession(3, 1) - - // Put a high-seq summary at the head; log order would place retained older nodes first. - const n0 = session.surface.nodes - await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'm') - - const n1 = session.surface.nodes - svc.summarizeCalls = [] - await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'm') - - // Extraction must match surface and `deriveMessages()` order. - const { text } = svc.summarizeCalls[0]! - const checkpointIdx = text.indexOf('compacted-summary') - const olderIdx = text.indexOf('turn 2 user') - expect(checkpointIdx).toBeGreaterThanOrEqual(0) - expect(olderIdx).toBeGreaterThan(checkpointIdx) - }) -}) - -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. - expect(BasicCompactService.inject).toContain('llm') - }) - - it('resolves ctx.llm and summarizes when mounted as a sibling plugin of LlmService', async () => { - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) - // Mount the service through its real plugin fiber (NOT new …(rootCtx)), so - // the sibling-fiber ctx.llm resolution actually exercises the inject. - const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false })) - - 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') - expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) - - // Tear the fiber down so this test owns no leaked registration; the - // dedicated cleanup assertion lives in the "HMR safety" suite. - await fiber.dispose() - expect(ctx.get('compact')).toBeUndefined() - }) -}) - -describe('BasicCompactService under the real invariants plugin', () => { - /** - * Drive compaction through a session whose `session/event` listeners include - * the real dev-mode invariants plugin (as a real app loads it via agent-core). - * The invariants throw on append, so a passing run proves the compaction - * sequence is contract-valid: every event is turn-enclosed, and the positional - * replace op is accepted even when the surface is no longer seq-ordered. - */ - async function setup(): Promise<{ ctx: Context; session: Session; svc: BasicCompactService }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(Invariants) - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) - await ctx.plugin(BasicCompactService, cfg({ auto: false })) - const session = ctx.sessions.create() - return { ctx, session, svc: ctx.compact as BasicCompactService } - } - - /** Append one closed turn of [user, assistant] surface nodes via the store. */ - function closedTurn(session: Session, turn: number): void { +/** Closed two-message turns followed by one open turn for durable compaction events. */ +function conversation(turns = 4, text = 'fixture'): Session { + const session = new Session(SessionId(`conversation-${turns}`)) + for (let turn = 1; turn <= turns; turn += 1) { session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `${text} user ${turn}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) session.append('step/start', { turn, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: `turn ${turn} user.${LONG_FIXTURE_TEXT}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant.${LONG_FIXTURE_TEXT}` }] }, { surfaceOp: 'append' }) + session.append('assistant/message', { + turn, + step: 1, + content: [{ type: 'text', text: `${text} assistant ${turn}` }], + }, { surfaceOp: 'append' }) session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } + session.append('turn/start', { + turn: turns + 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + return session +} - it('runs a turn-enclosed compaction whose positional replace the invariants accept', async () => { - const { session, svc } = await setup() - closedTurn(session, 1) - closedTurn(session, 2) - // Open turn 3, as the loop has when the auto-compaction listener fires. - session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) +function toolConversation(): Session { + const session = new Session(SessionId('tools')) + for (let turn = 1; turn <= 3; turn += 1) { + const callId = CallId(`call-${turn}`) + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `request ${turn} `.repeat(300) }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('step/start', { turn, step: 1 }) + session.append('assistant/message', { + turn, + step: 1, + content: [ + { type: 'text', text: `calling ${turn} `.repeat(300) }, + { type: 'tool-call', id: callId, name: 'read', arguments: '{}' }, + ], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn, step: 1, callId, name: 'read', arguments: '{}' }) + session.append('tool/result', { + turn, + step: 1, + callId, + content: [{ type: 'text', text: `result ${turn} `.repeat(300) }], + isError: false, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + return session +} - 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') - expect(result.shadowedSeqs.length).toBe(2) - expect(session.surface.nodes[0]!.seq).toBeGreaterThan(session.surface.nodes[1]!.seq) +class TestCompactService extends BasicCompactService { + summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }] + summaryModel = 'summary-model' + error: unknown + mutateDuringSummary: (() => void) | undefined + calls: Array<{ text: string; signal: AbortSignal | undefined }> = [] + + override async summarize( + text: string, + _agent: Agent, + signal?: AbortSignal, + ): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> { + this.calls.push({ text, signal }) + this.mutateDuringSummary?.() + if (this.error !== undefined) throw this.error + return { summary: this.summary, model: this.summaryModel, maxTokens: 123 } + } +} + +function service( + config: BasicCompactConfig = { auto: false }, + ctx = createContext(), +): TestCompactService { + return new TestCompactService(ctx, config) +} + +async function compactIfNeeded( + compact: BasicCompactService, + session: Session, + model: string | undefined = MODEL, + system = '', + prefix: readonly Message[] = [], +): Promise { + return compact.compactIfNeeded(agent(session, model), system, prefix, SIGNAL) +} + +describe('compact configuration and defaults', () => { + it('uses low-friction common and per-profile defaults', () => { + const ctx = createContext({ + [MODEL]: { contextWindow: 100, charsPerToken: 1_000 }, + large: { contextWindow: 1_000, charsPerToken: 4 }, + }) + const resolved = resolveConfig({}, ctx.tokenMeter) + + expect(resolved).toEqual({ + models: {}, + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, + auto: true, + }) + expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve(MODEL))).toEqual({ + model: MODEL, + contextWindow: 100, + thresholdRatio: 0.8, + retainTokens: 16, + }) + expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve('large')).retainTokens).toBe(160) + expect(Object.isFrozen(resolved)).toBe(true) }) - it('accepts a second compaction over the non-monotonic surface left by the first', async () => { - const { session, svc } = await setup() - closedTurn(session, 1) - closedTurn(session, 2) - closedTurn(session, 3) - session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + it('merges threshold and retention overrides field-wise', () => { + const ctx = createContext() + const thresholdOnly = resolveConfig({ + models: { [MODEL]: { thresholdRatio: 0.5 } }, + }, ctx.tokenMeter) + expect(resolveModelConfig(thresholdOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({ + thresholdRatio: 0.5, + retainTokens: 16, + }) - const n0 = session.surface.nodes - await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'test-model') + const retentionOnly = resolveConfig({ + models: { [MODEL]: { retainTokens: 7 } }, + }, ctx.tokenMeter) + expect(resolveModelConfig(retentionOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({ + thresholdRatio: 0.8, + retainTokens: 7, + }) + }) - // 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]) + it('validates common values and model policy invariants', () => { + const ctx = createContext() + const bad = [ + [{ maxTokens: 0 }, /maxTokens/], + [{ compactionRetries: -1 }, /compactionRetries/], + [{ auto: 'yes' }, /auto must be a boolean/], + [{ summarizationModel: 1 }, /summarizationModel must be a string/], + [{ models: null }, /models must be an object/], + [{ models: { [MODEL]: null } }, /must be an object/], + [{ models: { [MODEL]: { thresholdRatio: 0 } } }, /number in \(0, 1\]/], + [{ models: { [MODEL]: { thresholdRatio: 1.1 } } }, /number in \(0, 1\]/], + [{ models: { [MODEL]: { retainTokens: -1 } } }, /non-negative integer/], + [{ models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 50 } } }, /less than threshold/], + ] as Array<[unknown, RegExp]> + + for (const [config, pattern] of bad) { + expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern) + } + }) + + it('rejects an override for an unknown meter profile with the exact typed error', () => { + const ctx = createContext() + expect(() => resolveConfig({ models: { missing: { retainTokens: 1 } } }, ctx.tokenMeter)) + .toThrow(expect.objectContaining({ + code: TOKEN_METER_MODEL_UNCONFIGURED, + model: 'missing', + })) + }) +}) + +describe('pressure measurement and retention', () => { + const compactConfig: BasicCompactConfig = { + auto: false, + models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + } + + it('skips the provisional check only when no routed or fallback model exists', async () => { + const compact = service(compactConfig) + const session = conversation() + expect(await compact.compactIfNeeded(agent(session), '', [], SIGNAL)).toBeNull() + expect(compact.calls).toHaveLength(0) + }) + + it('throws for a named unconfigured model instead of swallowing it', async () => { + const compact = service(compactConfig) + await expect(compactIfNeeded(compact, conversation(), 'missing')) + .rejects.toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED, model: 'missing' }) + }) + + it('does nothing below threshold and compacts a priced head above threshold', async () => { + const compact = service(compactConfig) + expect(await compactIfNeeded(compact, conversation(2))).toBeNull() + + const session = conversation(4) + const result = await compactIfNeeded(compact, session) + expect(result).not.toBeNull() + expect(result?.shadowedSeqs.length).toBeGreaterThan(2) + expect(session.surface.nodes.length).toBeLessThan(8) + }) + + it('counts the current prompt and request prefix without putting either on the surface', async () => { + const compact = service({ + auto: false, + models: { [MODEL]: { thresholdRatio: 0.7, retainTokens: 9 } }, + }) + const session = conversation(2, 'x'.repeat(2_000)) + expect(await compactIfNeeded(compact, session)).toBeNull() + + const prefix: Message[] = [{ + role: 'user', + content: [{ type: 'text', text: 'p'.repeat(10_000) }], + }] + const result = await compactIfNeeded(compact, session, MODEL, 's'.repeat(5_000), prefix) + expect(result).not.toBeNull() + expect(prefix).toHaveLength(1) + expect(session.events.some(event => event.type === 'context/message')).toBe(false) + }) + + it('uses the latest logged routed model instead of AgentOptions.model', async () => { + const ctx = createContext({ + actual: { contextWindow: 100, charsPerToken: 1_000 }, + fallback: { contextWindow: 10_000, charsPerToken: 1_000 }, + }) + const compact = service({ + auto: false, + models: { actual: { thresholdRatio: 0.5, retainTokens: 18 } }, + }, ctx) + const session = conversation(4) + session.append('request/header', { + header: { config: { model: 'actual' } }, + reason: 'initial', + }) + + const result = await compactIfNeeded(compact, session, 'fallback') + expect(result).not.toBeNull() + }) + + it('declines when envelope pressure is high but the surface has no compactable range', async () => { + const compact = service(compactConfig) + const empty = new Session(SessionId('empty')) + expect(await compactIfNeeded(compact, empty, MODEL, 'x'.repeat(100_000))).toBeNull() + + const retained = conversation(1) + expect(await compactIfNeeded(compact, retained, MODEL, 'x'.repeat(100_000))).toBeNull() + }) + + it('detects scalar/surface revision disagreement', async () => { + const ctx = createContext() + const meter = ctx.tokenMeter.resolve(MODEL) + const original = meter.measureSurface.bind(meter) + vi.spyOn(meter, 'measureSurface').mockImplementation((session) => { + const measurement = original(session) + return { ...measurement, logRevision: measurement.logRevision - 1 } + }) + const compact = service(compactConfig, ctx) + + await expect(compactIfNeeded(compact, conversation(4))).rejects.toThrow(/revision/) + }) + + it('bounds retries when a shrinking checkpoint remains above threshold', async () => { + const compact = service({ + auto: false, + compactionRetries: 0, + models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + }) + compact.summary = Array.from({ length: 7 }, (_, index) => ({ + type: 'text', + text: `summary ${index}`, + })) + + await expect(compactIfNeeded(compact, conversation(4))) + .rejects.toThrow(/still above threshold after 1 compaction attempts/) + }) + + it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => { + const compact = service({ + auto: false, + models: { [MODEL]: { thresholdRatio: 0.8, retainTokens: 8 } }, + }) + const session = toolConversation() + const result = await compactIfNeeded(compact, session) + expect(result).not.toBeNull() + + const messages = session.deriveMessages() + const calls = new Set() + for (const message of messages) { + for (const block of message.content) { + if (block.type === 'tool-call') calls.add(block.id) + if (block.type === 'tool-result') expect(calls.has(block.toolCallId)).toBe(true) + } + } + }) + + it('rejects a priced surface that is not the current positional surface', () => { + const ctx = createContext() + const session = conversation(2) + const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session) + expect(() => selectCompactableRange(session, { + ...priced, + nodes: priced.nodes.slice(1), + }, 1)).toThrow(/does not match/) + }) + + it('declines when rounding a cut would consume the only tool pair', () => { + const ctx = createContext() + const session = new Session(SessionId('one-tool-pair')) + const callId = CallId('only') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' }) + session.append('tool/result', { + turn: 1, + step: 1, + callId, + content: [{ type: 'text', text: 'result' }], + isError: false, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + + const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session) + expect(selectCompactableRange(session, priced, 1)).toBeNull() + }) +}) + +describe('compaction region transaction', () => { + it('lands a framed, replayable checkpoint with exact pricing provenance', async () => { + const compact = service() + const session = conversation(3) + const before = session.surface.nodes + const result = await compact.compactRegion( + session, + before[0]!.seq, + before[3]!.seq, + agent(session, MODEL), + SIGNAL, + ) + + expect(result.shadowedSeqs).toEqual(before.slice(0, 4).map(node => node.seq)) + expect(result.shadowedTokenCount).toBeGreaterThan(0) + expect(compact.calls[0]).toMatchObject({ signal: SIGNAL }) + expect(compact.calls[0]?.text).toContain('fixture user 1') + const summary = session.events.findLast(event => event.type === 'compact/summary') + expect(summary?.data).toMatchObject({ + shadowedSeqs: result.shadowedSeqs, + shadowedTokenCount: result.shadowedTokenCount, + model: 'summary-model', + maxTokens: 123, + }) + const head = session.deriveMessages()[0]! + expect(head.content[0]?.type).toBe('text') + expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') + expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) + + const replay = new Session(SessionId('replay'), [...session.events]) + expect(replay.deriveMessages()).toEqual(session.deriveMessages()) + }) + + it.each([ + ['start missing', 9_001, undefined, /start seq 9001 not found/], + ['end missing', undefined, 9_002, /end seq 9002 not found/], + ])('rejects %s', async (_label, startOverride, endOverride, pattern) => { + const compact = service() + const session = conversation(2) + const nodes = session.surface.nodes + await expect(compact.compactRegion( + session, + startOverride ?? nodes[0]!.seq, + endOverride ?? nodes[1]!.seq, + agent(session, MODEL), + )).rejects.toThrow(pattern) + }) + + it('rejects reversed and tool-unbalanced positional boundaries', async () => { + const compact = service() + const plain = conversation(2) + const nodes = plain.surface.nodes + await expect(compact.compactRegion( + plain, + nodes[2]!.seq, + nodes[1]!.seq, + agent(plain, MODEL), + )).rejects.toThrow(/is after end/) + + const tools = toolConversation() + const toolNodes = tools.surface.nodes + await expect(compact.compactRegion( + tools, + toolNodes[2]!.seq, + toolNodes[4]!.seq, + agent(tools, MODEL), + )).rejects.toThrow(/start seq .* not a balanced boundary/) + await expect(compact.compactRegion( + tools, + toolNodes[0]!.seq, + toolNodes[1]!.seq, + agent(tools, MODEL), + )).rejects.toThrow(/end seq .* not a balanced boundary/) + }) + + it('requires an open turn and an idle compaction bracket', async () => { + const compact = service() + const closed = conversation(1) + closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + const nodes = closed.surface.nodes + await expect(compact.compactRegion( + closed, + nodes[0]!.seq, + nodes[1]!.seq, + agent(closed, MODEL), + )).rejects.toThrow(/no open turn/) + + const locked = conversation(1) + locked.append('compact/start', { turn: 2 }) + const lockedNodes = locked.surface.nodes + await expect(compact.compactRegion( + locked, + lockedNodes[0]!.seq, + lockedNodes[1]!.seq, + agent(locked, MODEL), + )).rejects.toThrow(/already in progress/) + }) + + it('rejects a session with no turn boundary at all', async () => { + const compact = service() + const session = new Session(SessionId('turnless')) + session.append('user/message', { + content: [{ type: 'text', text: 'orphan' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const node = session.surface.nodes[0]! + + await expect(compact.compactRegion( + session, + node.seq, + node.seq, + agent(session, MODEL), + )).rejects.toThrow(/no open turn/) + }) + + it('rejects a meter snapshot that changed before summarization began', async () => { + const ctx = createContext() + const meter = ctx.tokenMeter.resolve(MODEL) + const original = meter.measureSurface.bind(meter) + vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => { + const measurement = original(session) + return { ...measurement, nodes: measurement.nodes.slice(1) } + }) + const compact = service({ auto: false }, ctx) + const session = conversation(2) + const nodes = session.surface.nodes + + await expect(compact.compactRegion( + session, + nodes[0]!.seq, + nodes[2]!.seq, + agent(session, MODEL), + )).rejects.toThrow(/selected surface changed/) + }) + + it('records summarizer failures without mutating the surface', async () => { + const compact = service() + compact.error = new Error('summary unavailable') + const session = conversation(2) + const before = session.surface.nodes + + await expect(compact.compactRegion( + session, + before[0]!.seq, + before[2]!.seq, + agent(session, MODEL), + )).rejects.toThrow('summary unavailable') + expect(session.surface.nodes).toEqual(before) + expect(session.events.findLast(event => event.type === 'compact/end')?.data) + .toMatchObject({ error: 'summary unavailable' }) + }) + + it('stringifies non-Error failures in the durable end bracket', async () => { + const compact = service() + compact.error = 'plain failure' + const session = conversation(2) + const nodes = session.surface.nodes + await expect(compact.compactRegion( + session, + nodes[0]!.seq, + nodes[2]!.seq, + agent(session, MODEL), + )).rejects.toBe('plain failure') + expect(session.events.findLast(event => event.type === 'compact/end')?.data) + .toMatchObject({ error: 'plain failure' }) + }) + + it('rejects concurrent durable appends before committing the replacement', async () => { + const compact = service() + const session = conversation(2) + compact.mutateDuringSummary = () => { + session.append('request/header', { + header: { config: { model: MODEL } }, + reason: 'initial', + }) + } + const nodes = session.surface.nodes + + await expect(compact.compactRegion( + session, + nodes[0]!.seq, + nodes[2]!.seq, + agent(session, MODEL), + )).rejects.toThrow(/session log changed/) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) + }) + + it('rejects a non-shrinking framed summary under the conversation meter', async () => { + const compact = service() + compact.summary = Array.from({ length: 20 }, (_, index) => ({ + type: 'text', + text: `verbose ${index}`, + })) + const session = conversation(2) + const nodes = session.surface.nodes + + await expect(compact.compactRegion( + session, + nodes[0]!.seq, + nodes[2]!.seq, + agent(session, MODEL), + )).rejects.toThrow(/summary is not smaller/) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) + }) + + it('requires a conversation model for pricing', async () => { + const compact = service() + const session = conversation(1) + const nodes = session.surface.nodes + await expect(compact.compactRegion( + session, + nodes[0]!.seq, + nodes[1]!.seq, + agent(session), + )).rejects.toThrow(/no routed or configured conversation model/) + }) +}) + +class ScriptedAdapter extends LlmAdapter { + lastOptions: GenerateOptions | undefined + + constructor( + private readonly blocks: readonly ContentBlock[], + private readonly finish: (StreamChunk & { type: 'finish' })['reason'] = { kind: 'stop' }, + ) { + super() + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.lastOptions = options + for (const [index, block] of this.blocks.entries()) { + yield { type: 'block-start', index, blockType: block.type } + if (block.type === 'text') { + yield { type: 'text-delta', index, text: block.text } + } else if (block.type === 'reasoning') { + yield { type: 'reasoning-delta', index, text: block.text } + } else { + yield { type: 'block-end', index, block } + } + } + yield { type: 'finish', reason: this.finish } + } +} + +async function summarizerHarness( + blocks: readonly ContentBlock[], + finish?: (StreamChunk & { type: 'finish' })['reason'], + model = MODEL, + config: BasicCompactConfig = { auto: false }, +): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> { + const ctx = new Context() + await ctx.plugin(LlmService) + void new TokenMeterService(ctx, { models: { [model]: { contextWindow: 100 } } }) + const adapter = new ScriptedAdapter(blocks, finish) + ctx.llm.registerAdapter([model], adapter) + const compact = new BasicCompactService(ctx, config) + return { ctx, adapter, compact } +} + +describe('default one-shot summarizer', () => { + it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => { + const { adapter, compact } = await summarizerHarness([ + { type: 'reasoning', text: 'private' }, + { type: 'text', text: 'public summary' }, + { type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' }, + ], undefined, MODEL, { + auto: false, + summarizationModel: MODEL, + maxTokens: 321, + }) + const session = conversation(1) + const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL) + + expect(output).toEqual({ + summary: [{ type: 'text', text: 'public summary' }], + model: MODEL, + maxTokens: 321, + }) + expect(adapter.lastOptions).toMatchObject({ + model: MODEL, + maxTokens: 321, + signal: SIGNAL, + sessionId: session.id, + }) + expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent') + }) + + it('resolves latest routed model before AgentOptions.model', async () => { + const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed') + const session = conversation(1) + session.append('request/header', { + header: { config: { model: 'routed' } }, + reason: 'initial', + }) + const output = await compact.summarize('history', agent(session, 'fallback')) + expect(output.model).toBe('routed') + expect(adapter.lastOptions?.model).toBe('routed') + }) + + it('fails clearly when no summarization model can be resolved', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + void new TokenMeterService(ctx) + const compact = new BasicCompactService(ctx, { auto: false }) + await expect(compact.summarize('history', agent(new Session(SessionId('model-less'))))) + .rejects.toThrow(/no model available for summarization/) + }) + + it.each([ + [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/], + [{ kind: 'error', message: 'opaque' }, undefined, /opaque/], + [{ kind: 'aborted' }, 'ABORTED', /aborted/], + [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/], + ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) ( + 'rejects terminal finish %#', + async (finish, code, pattern) => { + const { compact } = await summarizerHarness([], finish) + let thrown: unknown + try { + await compact.summarize('history', agent(conversation(1), MODEL)) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toMatch(pattern) + expect((thrown as Error & { code?: string }).code).toBe(code) + }, + ) + + it('rejects empty or reasoning-only successful output', async () => { + const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }]) + await expect(compact.summarize('history', agent(conversation(1), MODEL))) + .rejects.toThrow(/no text summary content/) + }) +}) + +describe('automatic listener and loader composition', () => { + function preStep(ctx: Context, owner: Agent): Promise { + return ctx.serial('agent/pre-step', owner, 1, 1, '', [], SIGNAL) + } + + it('compacts above threshold and remains idle below it', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx, { + models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + }) + const pressured = conversation(4) + await preStep(ctx, agent(pressured, MODEL)) + expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true) + + const small = conversation(1) + await preStep(ctx, agent(small, MODEL)) + expect(small.events.some(event => event.type === 'compact/start')).toBe(false) + expect(compact.calls).toHaveLength(1) + }) + + it('warns and continues after operational failures, including non-Errors', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + const compact = new TestCompactService(ctx, { + models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + }) + compact.error = 'temporary failure' + const session = conversation(4) + + await expect(preStep(ctx, agent(session, MODEL))).resolves.toBeUndefined() + expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) + }) + + it('propagates a named unknown-model configuration failure', async () => { + const ctx = createContext() + void new TestCompactService(ctx) + await expect(preStep(ctx, agent(conversation(4), 'missing'))).rejects.toMatchObject({ + code: TOKEN_METER_MODEL_UNCONFIGURED, + model: 'missing', + }) + }) + + it('auto:false installs no listener', async () => { + const ctx = createContext() + void new TestCompactService(ctx, { + auto: false, + models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + }) + const session = conversation(4) + await preStep(ctx, agent(session, MODEL)) + expect(session.events.some(event => event.type === 'compact/start')).toBe(false) + }) + + it('loads and disposes the real zero-config service stack', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const meterFiber = await ctx.plugin(TokenMeterService) + const compactFiber = await ctx.plugin(BasicCompactService, { auto: false }) + + expect(ctx.tokenMeter.resolve('deepseek-v4-flash').contextWindow).toBe(128_000) + expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) + await compactFiber.dispose() + expect(ctx.get('compact')).toBeUndefined() + await meterFiber.dispose() + expect(ctx.get('tokenMeter')).toBeUndefined() + }) + + it('removes its automatic listener with the plugin fiber', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(TokenMeterService, { + models: { [MODEL]: { contextWindow: 100, charsPerToken: 1_000 } }, + }) + const fiber = await ctx.plugin(TestCompactService, { + models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + }) + await fiber.dispose() + + const session = conversation(4) + await preStep(ctx, agent(session, MODEL)) + expect(session.events.some(event => event.type === 'compact/start')).toBe(false) + }) +}) + +describe('typed unknown-model boundary', () => { + it('uses TokenMeterError identity rather than message matching', () => { + const ctx = createContext() + let thrown: unknown + try { + ctx.tokenMeter.resolve('missing') + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(TokenMeterError) + expect(thrown).toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED }) }) }) 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 49fb99acec..cc1111c5f1 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -11,6 +11,7 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { SurfaceEvent } from '@deepseek-ai/dsh-session' /** @@ -20,13 +21,7 @@ import type { SurfaceEvent } from '@deepseek-ai/dsh-session' * surface-position semantics rather than raw-log scanning. */ -const TOKENS_PER_BLOCK = 10 - class ReproCompactService extends BasicCompactService { - override estimateContentTokens(blocks: readonly ContentBlock[]): number { - return blocks.length * TOKENS_PER_BLOCK - } - override async summarize(): Promise<{ summary: ContentBlock[]; model: string }> { return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], model: 'stub' } } @@ -67,6 +62,9 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TokenMeterService, { + models: { mock: { contextWindow: 64, charsPerToken: 1_000 } }, + }) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) ctx.tools.register(defineTool({ name: 'work', @@ -80,9 +78,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr // fires within the runaway turn. const compact = new ReproCompactService(ctx, { auto: true, - contextWindow: 64, - thresholdRatio: 0.5, - retainTokens: 20, + models: { mock: { thresholdRatio: 0.5, retainTokens: 20 } }, summarizationModel: '', maxTokens: 8192, compactionRetries: 1, diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..ff6320c05f --- /dev/null +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -0,0 +1,66 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import BasicCompactService from '@deepseek-ai/dsh-compact-basic' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('real Loader composition', () => { + it('loads the zero-config token-meter then compact-basic YAML pair', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-llm'", + "- name: '@deepseek-ai/dsh-token-meter'", + "- name: '@deepseek-ai/dsh-compact-basic'", + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmService], + ['@deepseek-ai/dsh-token-meter', TokenMeterService], + ['@deepseek-ai/dsh-compact-basic', BasicCompactService], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + const unloaded = [...context.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + expect(context.tokenMeter.resolve('deepseek-v4-flash')).toMatchObject({ + contextWindow: 128_000, + charsPerToken: 4, + }) + expect(context.get('compact')).toBeInstanceOf(BasicCompactService) + }) +}) diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index 075c64cb61..0103ad82a8 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -8,7 +8,9 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, + { "path": "../../llm/token-meter" }, { "path": "../../core/session" }, { "path": "../../core/agent" }, { "path": "../compact" } diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 5b59893561..bbaeff0b17 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -7,14 +7,14 @@ This package is the interface tier of the compaction capability, split so each c | Package | Role | |---|---| | `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) | -| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | +| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). ## Service API (`ctx.compact`) -Both methods are **abstract** — the backend owns the entire strategy (token estimation, retention policy, event sequencing, summarization). +Both methods are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface. | Member | Semantics | |---|---| @@ -53,7 +53,7 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati ## Implementing a backend -Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. A tokenizer-, template-, or model-backed implementation can live as a sibling package without changing callers. +Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter. ## Model Experience diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 12eb77b362..6d32ad0159 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -29,10 +29,11 @@ declare module 'cordis' { } /** - * Abstract compaction service. Implementations own token estimation, retention, - * and summarization, but a successful run must replace the selected surface span - * with one summary node and prevent concurrent compaction of the same session. - * Load one implementation per context as `ctx.compact`. + * Abstract compaction service. Implementations own trigger policy, retention, + * and summarization, and may consume a separate measurement service. A + * successful run replaces the selected surface span with one summary node and + * prevents concurrent compaction of the same session. Load one implementation + * per context as `ctx.compact`. */ export abstract class CompactService extends Service { constructor(ctx: Context) { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 22b1d8dc79..9e726d2e3a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -214,6 +214,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async assemble(context: AssembleContext = {}): Promise', ], }, + { + key: 'tokenMeter', + summary: 'Concrete registry and replay owner for all configured model meters.', + methods: [ + 'resolve(model: string): ModelTokenMeter', + ], + }, { key: 'tools', summary: 'Tool registry and execution pipeline.', @@ -669,6 +676,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'EpochHeader', + declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n}', + }, { name: 'FileDiff', declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}', @@ -737,6 +748,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n}', }, + { + name: 'LlmCallConfig', + declaration: 'export interface LlmCallConfig {\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', + }, { name: 'Message', declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n}', @@ -749,6 +764,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}', }, + { + name: 'ModelTokenMeter', + declaration: 'export interface ModelTokenMeter {\n readonly model: string;\n readonly contextWindow: number;\n readonly charsPerToken: number;\n measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement;\n measureSurface(session: Session): TokenSurfaceMeasurement;\n estimateMessage(message: Message): number;\n}', + }, { name: 'OwnerToken', declaration: 'export type OwnerToken = Branded<\'OwnerToken\'>;', @@ -941,6 +960,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TodoItem', declaration: 'export interface TodoItem {\n content: string;\n status: \'pending\' | \'in_progress\' | \'completed\';\n}', }, + { + name: 'TokenMeasurement', + declaration: 'export interface TokenMeasurement {\n readonly model: string;\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n}', + }, + { + name: 'TokenMeasurementBaseline', + declaration: 'export type TokenMeasurementBaseline = {\n readonly kind: \'none\';\n readonly tokens: 0;\n} | {\n readonly kind: \'estimated\';\n readonly tokens: number;\n} | {\n readonly kind: \'usage\';\n readonly tokens: number;\n readonly usage: Readonly;\n};', + }, + { + name: 'TokenSurfaceMeasurement', + declaration: 'export interface TokenSurfaceMeasurement {\n readonly model: string;\n readonly logRevision: number;\n readonly totalTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}', + }, + { + name: 'TokenSurfaceNode', + declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n}', + }, { name: 'TokenUsage', declaration: 'export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 91ed9d5049..99aef8b9cf 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -50,6 +50,8 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re The driver owns one agent for its lifetime. It records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. +Every provider call that reaches a successful finish appends one `assistant/message` completion anchor after `agent/step-result`, including content-less calls and `max-tokens` finishes. The anchor records exact chunk provenance (`[]` for a stream with no chunks) and usage when available; empty content stays out of derived message history while those replay facts remain durable. + Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush. ### What belongs to plugins diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 18eca40cc1..af42d8ff6a 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -528,15 +528,14 @@ async function runStep( 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))) - // Preserve usage even when max-token truncation produced no content. - if (message.content.length > 0 || assembler.usage) { - // The finish chunk guarantees non-empty provenance here. - session.append( - 'assistant/message', - { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) - } + // Every successful call records its completion anchor. Empty content is + // skipped by deriveMessages(), while exact chunk provenance lets replay + // distinguish a known empty provider stream from unrecorded provenance. + session.append( + 'assistant/message', + { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + ) return { hadToolCalls: false, finish: assembler.finish } } @@ -544,14 +543,14 @@ async function runStep( let message: Message = assembler.message() message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)) - // Empty messages exist only to carry usage; omit empty provenance. - if (message.content.length > 0 || assembler.usage) { - session.append( - 'assistant/message', - { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, - { surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) }, - ) - } + // Every successful call records its completion anchor. A present empty + // source set means the provider stream was known to contain no chunks; + // omission remains the conservative legacy/unrecorded representation. + session.append( + 'assistant/message', + { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + ) // Tool execution stays sequential; recheck abort around each normalized result. const toolCalls = message.content.filter(block => block.type === 'tool-call') diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 8130d4b893..4b2d9df1d3 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1075,9 +1075,10 @@ describe('tool result call identity', () => { }) }) -describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => { - it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => { - // Injected result content with no chunks must omit empty sourceEventSeqs. +describe('surface: assistant/message records exact empty provenance when no chunks streamed', () => { + it('a step-result listener injecting content over an empty stream records sourceEventSeqs []', async () => { + // The explicit empty source set distinguishes a known empty provider + // stream from legacy events whose provenance was not recorded. const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) await ctx.plugin(Invariants) @@ -1094,7 +1095,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream const recorded = agent.session.events.find(e => e.type === 'assistant/message')! expect(recorded.type).toBe('assistant/message') expect(recorded.surfaceOp).toBe('append') - expect(recorded.sourceEventSeqs).toBeUndefined() + expect(recorded.sourceEventSeqs).toEqual([]) // The injected content reaches derived history. expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected') }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fb686928b1..f282301a2b 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -717,10 +717,9 @@ describe('agent loop', () => { }) }) - it('appends no assistant/message for a max-tokens step with empty content and no usage', async () => { - // A max-tokens step truncated to a dropped tool call AND with no usage chunk has nothing to - // record: empty content and no accounting → no assistant/message (the empty-content host - // exists only to carry usage). + it('appends an empty completion anchor for a max-tokens step with no usage', async () => { + // The truncated tool call is dropped from durable content, while the + // successful provider call still needs an exact replay anchor. const callId = CallId('c1') const adapter = new MockAdapter([[ { type: 'block-start', index: 0, blockType: 'tool-call' }, @@ -744,14 +743,19 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'max-tokens' }]) - expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + const assistant = agent.session.events.find(e => e.type === 'assistant/message')! + expect(assistant.type === 'assistant/message' && assistant.data).toEqual({ + turn: 1, + step: 1, + content: [], + }) + expect(assistant.sourceEventSeqs?.length).toBeGreaterThan(0) expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) }) - it('appends no assistant/message for a normal stop finish with empty content and no usage', async () => { - // A clean `stop` finish that streamed nothing assembled (no blocks) and - // carried no usage chunk has nothing to record: the content-or-usage guard - // on the normal step path suppresses a pure trace-only empty assistant/message. + it('appends an empty completion anchor for a normal stop with no usage', async () => { + // A clean content-less call stays absent from derived messages but remains + // a durable successful-call boundary for replay consumers. const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -763,7 +767,13 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'completed' }]) - expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + const assistant = agent.session.events.find(e => e.type === 'assistant/message')! + expect(assistant.type === 'assistant/message' && assistant.data).toEqual({ + turn: 1, + step: 1, + content: [], + }) + expect(assistant.sourceEventSeqs?.length).toBe(1) expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) }) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 2540f988c7..3a079fe860 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -66,7 +66,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 nodes behind a compaction replace node). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means legacy or otherwise unrecorded provenance; other surface events require a non-empty list when this field is present. - `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors). ### Metadata types (`types.ts`) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f4f42062fd..da31751f95 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -331,6 +331,12 @@ export type SurfaceOp = */ export interface SurfaceIntent { surfaceOp: SurfaceOp + /** + * Complete known provenance source set. `assistant/message` may use a + * present empty array for a known empty provider stream; omission means its + * provenance was not recorded. Other surface events require a non-empty set + * when this field is present. + */ sourceEventSeqs?: number[] } @@ -359,7 +365,9 @@ export type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction replace node). + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ diff --git a/packages/llm/README.md b/packages/llm/README.md index 3fc5c9cf9e..f4d9dd82cb 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -5,7 +5,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a | Package | Role | ctx key | |---|---|---| | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | +| `token-meter/` | Replay-aware, per-model request and surface token measurement | `ctx.tokenMeter` | | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist. +The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist and the [replay token meter RFC](../../docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership. diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md new file mode 100644 index 0000000000..e50ee84b3b --- /dev/null +++ b/packages/llm/token-meter/README.md @@ -0,0 +1,57 @@ +# @deepseek-ai/dsh-token-meter + +Replay-aware token measurement through `ctx.tokenMeter`. The service binds one stable meter to each configured model and advances isolated per-model/per-session folds from the durable session log. Compaction consumes it today; other pressure-sensitive plugins can reuse the same accounting without depending on `CompactService`. + +## Profiles and configuration + +The built-in `deepseek-v4-flash` and `deepseek-v4-pro` profiles each use a 128,000-token context window and four characters per estimated token. `models` merges overrides field-by-field, so changing only density keeps the built-in window. A custom model requires `contextWindow`; its `charsPerToken` defaults to `4`. + +| Key | Default | Contract | +|---|---:|---| +| `models..contextWindow` | `128000` | Positive integer provider capacity. | +| `models..charsPerToken` | `4` | Positive finite heuristic density. | + +Resolving an unknown model throws `TokenMeterError` with code `TOKEN_METER_MODEL_UNCONFIGURED` and preserves the exact model name. Direct-construction profile validation uses `TOKEN_METER_INVALID_CONFIG`; Loader mounts first apply the package's Schemastery shape validation. There is no universal fallback window. + +## Measurement contract + +`ctx.tokenMeter.resolve(model)` returns a `ModelTokenMeter` with three operations: + +- `measure(session, requestHeader?)` returns scalar request pressure at one consumed-log revision. +- `measureSurface(session)` returns current surface nodes and their per-node prices at the same kind of revision. +- `estimateMessage(message)` prices one detached message under that profile. + +Measurements are detached and deeply immutable. A caller that needs a consistent scalar/surface decision compares their `logRevision` values instead of copying the full history on every read. + +The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the handle's model and the canonical request envelope match the successful-call anchor. Otherwise the complete current envelope and surface are repriced under the requested model. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements. + +Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output. + +## Composition + +```yaml +- name: '@deepseek-ai/dsh-token-meter' +- name: '@deepseek-ai/dsh-compact-basic' +``` + +Both plugins have usable defaults for the bundled DeepSeek profiles. Custom deployments can override only the fields that differ: + +```yaml +- name: '@deepseek-ai/dsh-token-meter' + config: + models: + deepseek-v4-flash: + charsPerToken: 2 + local-model: + contextWindow: 32768 +``` + +## Model Experience + +Indirectly, through consumers such as `dsh-compact-basic`; the service itself adds no prompt, message, schema, tool, or model call. + +## Known Limitations and Deferred Work + +- **Heuristic density still needs maintenance** — message content without provider usage is priced by configured character density plus structural overhead, not an exact provider tokenizer. CJK-heavy or provider-specific formats may need profile overrides. +- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, or call-config changes deliberately fall back to full heuristic repricing. +- **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream. diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json new file mode 100644 index 0000000000..30031b0b2e --- /dev/null +++ b/packages/llm/token-meter/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-token-meter", + "description": "Replay-aware per-model token measurement service (ctx.tokenMeter) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts new file mode 100644 index 0000000000..19d700ac96 --- /dev/null +++ b/packages/llm/token-meter/src/index.ts @@ -0,0 +1,193 @@ +/** + * Replay token-meter service with model-specific context capacity and pricing. + * + * @module @deepseek-ai/dsh-token-meter + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' +import type { Session } from '@deepseek-ai/dsh-session' +import { ReplayModelTokenMeter } from './replay.ts' +import type { ModelTokenProfile } from './replay.ts' +import type { + ModelTokenMeter, + ModelTokenMeterConfig, + TokenMeterConfig, +} from './types.ts' + +export type * from './types.ts' + +/** Exact error code for resolving a model without a configured profile. */ +export const TOKEN_METER_MODEL_UNCONFIGURED = 'TOKEN_METER_MODEL_UNCONFIGURED' + +/** Exact error code for invalid token-meter configuration. */ +export const TOKEN_METER_INVALID_CONFIG = 'TOKEN_METER_INVALID_CONFIG' + +/** Closed machine-routable token-meter failure taxonomy. */ +export type TokenMeterErrorCode = + | typeof TOKEN_METER_MODEL_UNCONFIGURED + | typeof TOKEN_METER_INVALID_CONFIG + +/** Built-in DeepSeek model profiles available with zero configuration. */ +const BUILTIN_TOKEN_PROFILES: Readonly>> = deepFreeze({ + 'deepseek-v4-flash': { + model: 'deepseek-v4-flash', + contextWindow: 128_000, + charsPerToken: 4, + }, + 'deepseek-v4-pro': { + model: 'deepseek-v4-pro', + contextWindow: 128_000, + charsPerToken: 4, + }, +}) + +/** Typed token-meter failure with the affected model preserved for callers. */ +export class TokenMeterError extends HarnessError { + declare readonly code: TokenMeterErrorCode + /** Exact model name involved in this error, when applicable. */ + readonly model: string | undefined + + constructor(message: string, code: TokenMeterErrorCode, model?: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'TokenMeterError' + this.model = model + } +} + +declare module 'cordis' { + interface Context { + tokenMeter: TokenMeterService + } +} + +/** Validate and detach all configured model profiles. */ +function resolveProfiles(config: TokenMeterConfig): readonly ModelTokenProfile[] { + const profiles = new Map() + for (const profile of Object.values(BUILTIN_TOKEN_PROFILES)) { + profiles.set(profile.model, { ...profile }) + } + + const configuredValue: unknown = config.models + const configuredModels = configuredValue === undefined ? {} : configuredValue + if (typeof configuredModels !== 'object' + || configuredModels === null + || Array.isArray(configuredModels)) { + throw new TokenMeterError( + 'TokenMeterConfig: models must be an object', + TOKEN_METER_INVALID_CONFIG, + ) + } + + for (const [model, override] of Object.entries(configuredModels as Record)) { + if (model.length === 0) { + throw new TokenMeterError( + 'TokenMeterConfig: model names must not be empty', + TOKEN_METER_INVALID_CONFIG, + model, + ) + } + assertProfileObject(model, override) + const builtIn = profiles.get(model) + const contextWindow = override.contextWindow ?? builtIn?.contextWindow + const charsPerToken = override.charsPerToken ?? builtIn?.charsPerToken ?? 4 + if (contextWindow === undefined) { + throw new TokenMeterError( + `TokenMeterConfig: custom model "${model}" requires contextWindow`, + TOKEN_METER_INVALID_CONFIG, + model, + ) + } + assertPositiveInteger(model, 'contextWindow', contextWindow) + assertPositiveFinite(model, 'charsPerToken', charsPerToken) + profiles.set(model, { model, contextWindow, charsPerToken }) + } + + for (const profile of profiles.values()) { + assertPositiveInteger(profile.model, 'contextWindow', profile.contextWindow) + assertPositiveFinite(profile.model, 'charsPerToken', profile.charsPerToken) + } + return deepFreeze([...profiles.values()].map(profile => ({ ...profile }))) +} + +function assertProfileObject(model: string, value: unknown): asserts value is ModelTokenMeterConfig { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TokenMeterError( + `TokenMeterConfig: profile "${model}" must be an object`, + TOKEN_METER_INVALID_CONFIG, + model, + ) + } +} + +function assertPositiveInteger(model: string, name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0) { + throw new TokenMeterError( + `TokenMeterConfig: ${model}.${name} (${value}) must be a positive integer`, + TOKEN_METER_INVALID_CONFIG, + model, + ) + } +} + +function assertPositiveFinite(model: string, name: string, value: number): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new TokenMeterError( + `TokenMeterConfig: ${model}.${name} (${value}) must be a positive finite number`, + TOKEN_METER_INVALID_CONFIG, + model, + ) + } +} + +/** Concrete registry and replay owner for all configured model meters. */ +export class TokenMeterService extends Service { + static Config: z = z.object({ + models: z.dict(z.object({ + contextWindow: z.number(), + charsPerToken: z.number(), + })), + }) + + private readonly meters = new Map() + + constructor(ctx: Context, config: TokenMeterConfig = {}) { + super(ctx, 'tokenMeter') + for (const profile of resolveProfiles(config)) { + this.meters.set(profile.model, new ReplayModelTokenMeter(profile)) + } + + // Readers catch up independently, while eager observation bounds ordinary + // read latency. A reader in an earlier listener consumes the new event; + // this listener then sees the same revision and performs no duplicate fold. + ctx.on('session/event', (session) => { + this._observe(session) + }) + } + + /** + * Resolve one stable model-bound replay handle. + * @param model - exact routed model name. + * @throws {@link TokenMeterError} with `TOKEN_METER_MODEL_UNCONFIGURED` when no profile exists. + * @returns the configured handle for this model. + */ + resolve(model: string): ModelTokenMeter { + const meter = this.meters.get(model) + if (meter === undefined) { + throw new TokenMeterError( + `token meter has no profile for model "${model}"`, + TOKEN_METER_MODEL_UNCONFIGURED, + model, + ) + } + return meter + } + + /** Advance every configured model's isolated replay fold. */ + private _observe(session: Session): void { + for (const meter of this.meters.values()) meter.observeIfActive(session) + } +} + +export default TokenMeterService diff --git a/packages/llm/token-meter/src/replay.ts b/packages/llm/token-meter/src/replay.ts new file mode 100644 index 0000000000..8b4af52dc5 --- /dev/null +++ b/packages/llm/token-meter/src/replay.ts @@ -0,0 +1,367 @@ +/** + * Model-bound transactional replay of request headers, surface mutations, and + * successful-call token anchors. + * + * @module @deepseek-ai/dsh-token-meter/replay + */ + +import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' +import { applyHeaderDelta, canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { + ModelTokenMeter, + TokenMeasurement, + TokenMeasurementBaseline, + TokenSurfaceMeasurement, + TokenSurfaceNode, +} from './types.ts' + +/** Internal validated pricing profile. */ +export interface ModelTokenProfile { + readonly model: string + readonly contextWindow: number + readonly charsPerToken: number +} + +/** Per-block structural overhead for JSON framing and type tags. */ +const BLOCK_OVERHEAD = 4 + +/** Role-field framing overhead added to every priced message. */ +const ROLE_OVERHEAD = 4 + +interface UsageAnchor { + readonly header: EpochHeader + readonly surfaceTokens: number + readonly baseline: Exclude +} + +interface ReplayState { + consumedEvents: number + header: EpochHeader | undefined + surface: TokenSurfaceNode[] + surfaceTokens: number + stepStart: { turn: number; step: number; surfaceTokens: number } | undefined + anchor: UsageAnchor | undefined +} + +interface PreparedSurfaceMutation { + readonly tokens: number + commit(state: ReplayState): void +} + +/** Sum disjoint provider usage buckets without double-counting reasoning output. */ +function usageTokens(usage: TokenUsage): number { + return usage.inputTokens + + (usage.cacheReadTokens ?? 0) + + (usage.cacheWriteTokens ?? 0) + + usage.outputTokens +} + +/** One configured model's replay fold, weakly isolated by session identity. */ +export class ReplayModelTokenMeter implements ModelTokenMeter { + readonly model: string + readonly contextWindow: number + readonly charsPerToken: number + + private readonly states = new WeakMap() + + constructor(profile: ModelTokenProfile) { + this.model = profile.model + this.contextWindow = profile.contextWindow + this.charsPerToken = profile.charsPerToken + } + + /** + * Advance an already-read model/session fold without creating unused state. + * @param session - session whose durable tail advanced. + */ + observeIfActive(session: Session): void { + if (this.states.has(session)) this._sync(session) + } + + /** @inheritdoc */ + estimateMessage(message: Message): number { + return this._estimateContent(message.content) + ROLE_OVERHEAD + } + + /** @inheritdoc */ + measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement { + const state = this._sync(session) + const header = requestHeader === undefined + ? state.header + : canonicalHeader(requestHeader) + const anchor = state.anchor + + let baseline: TokenMeasurementBaseline + let surfaceDeltaTokens: number + if (anchor !== undefined && header !== undefined && headerEquals(anchor.header, header)) { + baseline = anchor.baseline + surfaceDeltaTokens = state.surfaceTokens - anchor.surfaceTokens + } else if (header === undefined && state.surfaceTokens === 0) { + baseline = { kind: 'none', tokens: 0 } + surfaceDeltaTokens = 0 + } else { + baseline = { + kind: 'estimated', + tokens: this._estimateHeader(header) + state.surfaceTokens, + } + surfaceDeltaTokens = 0 + } + + return deepFreeze(structuredClone({ + model: this.model, + logRevision: state.consumedEvents, + baseline, + surfaceDeltaTokens, + totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens), + })) + } + + /** @inheritdoc */ + measureSurface(session: Session): TokenSurfaceMeasurement { + const state = this._sync(session) + return deepFreeze(structuredClone({ + model: this.model, + logRevision: state.consumedEvents, + totalTokens: state.surfaceTokens, + nodes: state.surface, + })) + } + + /** Catch one session's fold up to the current durable tail. */ + private _sync(session: Session): ReplayState { + let state = this.states.get(session) + if (state === undefined) { + state = { + consumedEvents: 0, + header: undefined, + surface: [], + surfaceTokens: 0, + stepStart: undefined, + anchor: undefined, + } + this.states.set(session, state) + } + + while (state.consumedEvents < session.events.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- contiguous session seqs index the durable log + const event = session.events[state.consumedEvents]! + this._foldEvent(session, state, event) + state.consumedEvents += 1 + } + return state + } + + /** + * Validate and prepare every fallible part before mutating replay state. + * A malformed event therefore remains the next unread event on every retry + * instead of applying a partial surface mutation twice. + */ + private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void { + let nextHeader = state.header + let nextStepStart = state.stepStart + let nextAnchor = state.anchor + + switch (event.type) { + case 'request/header': + nextHeader = canonicalHeader(event.data.header) + break + case 'request/header-delta': + if (state.header === undefined) { + throw new Error(`token meter: request/header-delta at seq ${event.seq} has no preceding header`) + } + nextHeader = applyHeaderDelta(state.header, event.data) + break + case 'step/start': + if (state.stepStart !== undefined) { + throw new Error( + `token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`, + ) + } + nextStepStart = { ...event.data, surfaceTokens: state.surfaceTokens } + break + case 'step/end': + if (state.stepStart === undefined + || state.stepStart.turn !== event.data.turn + || state.stepStart.step !== event.data.step) { + throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`) + } + nextStepStart = undefined + break + default: + break + } + + const surface = isSurfaceEvent(event) + ? this._prepareSurfaceMutation(session, state, event) + : undefined + + if (event.type === 'assistant/message' && nextHeader?.config.model === this.model) { + const stepStart = state.stepStart + if (stepStart === undefined + || stepStart.turn !== event.data.turn + || stepStart.step !== event.data.step) { + throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`) + } + + // assistant/message is surface-mandatory at every append/seed boundary. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const eventTokens = surface!.tokens + if (event.data.usage !== undefined) { + const providerAssistantTokens = this._estimateProviderAssistant( + session, + event, + eventTokens, + ) + nextAnchor = { + header: nextHeader, + surfaceTokens: stepStart.surfaceTokens + providerAssistantTokens, + baseline: { + kind: 'usage', + tokens: usageTokens(event.data.usage), + usage: event.data.usage, + }, + } + } else { + const anchorSurfaceTokens = stepStart.surfaceTokens + eventTokens + nextAnchor = { + header: nextHeader, + surfaceTokens: anchorSurfaceTokens, + baseline: { + kind: 'estimated', + tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens, + }, + } + } + } + + state.header = nextHeader + state.stepStart = nextStepStart + if (surface !== undefined) surface.commit(state) + state.anchor = nextAnchor + } + + /** Validate one surface operation and return its allocation-light commit. */ + private _prepareSurfaceMutation( + session: Session, + state: ReplayState, + event: SurfaceEvent, + ): PreparedSurfaceMutation { + const tokens = this._estimateSurfaceEvent(session, event) + const op = event.surfaceOp + if (op === 'append') { + return { + tokens, + commit(target) { + target.surface.push({ seq: event.seq, tokens }) + target.surfaceTokens += tokens + }, + } + } + + const startIdx = state.surface.findIndex(node => node.seq === op.start) + const endIdx = state.surface.findIndex(node => node.seq === op.end) + if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { + throw new Error( + `token meter: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, + ) + } + const removedTokens = state.surface + .slice(startIdx, endIdx + 1) + .reduce((total, node) => total + node.tokens, 0) + return { + tokens, + commit(target) { + target.surface.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) + target.surfaceTokens += tokens - removedTokens + }, + } + } + + /** Price one current surface event exactly as it projects to a request. */ + private _estimateSurfaceEvent(session: Session, event: SurfaceEvent): number { + const message = session.deriveEventMessage(event) + return message === null ? 0 : this.estimateMessage(message) + } + + /** + * Reassemble provider output from exact chunk provenance for a usage anchor. + * Missing legacy provenance conservatively treats the durable output as the + * provider output; explicit empty provenance prices a known empty stream. + */ + private _estimateProviderAssistant( + session: Session, + event: SessionEvent<'assistant/message'>, + durableEventTokens: number, + ): number { + const sourceSeqs = event.sourceEventSeqs + if (sourceSeqs === undefined) return durableEventTokens + + const assembler = new BlockAssembler() + const seen = new Set() + for (const seq of sourceSeqs) { + if (seq >= event.seq) { + throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not earlier`) + } + if (seen.has(seq)) { + throw new Error(`token meter: assistant/message at seq ${event.seq} repeats source seq ${seq}`) + } + seen.add(seq) + // Session construction validates contiguous seqs, and the explicit + // earlier-than-assistant check above therefore guarantees existence. + const source = session.events[seq] + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sourceEvent = source! + if (sourceEvent.type !== 'assistant/chunk') { + throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`) + } + if (sourceEvent.data.turn !== event.data.turn || sourceEvent.data.step !== event.data.step) { + throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} belongs to another step`) + } + assembler.push(sourceEvent.data.chunk) + } + const providerMessage = assembler.message() + return providerMessage.content.length === 0 ? 0 : this.estimateMessage(providerMessage) + } + + /** Price content blocks recursively under this model's density profile. */ + private _estimateContent(blocks: readonly ContentBlock[]): number { + let tokens = 0 + for (const block of blocks) { + switch (block.type) { + case 'text': + case 'reasoning': + tokens += Math.ceil(block.text.length / this.charsPerToken) + BLOCK_OVERHEAD + break + case 'tool-call': + tokens += Math.ceil(block.name.length / this.charsPerToken) + + Math.ceil(block.arguments.length / this.charsPerToken) + + BLOCK_OVERHEAD + break + case 'tool-result': + tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD + break + default: + // ContentBlockMap is merge-extensible; unknown blocks retain a + // conservative structural JSON price under the selected profile. + tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / this.charsPerToken) + } + } + return tokens + } + + /** Price the canonical non-surface request envelope. */ + private _estimateHeader(header: EpochHeader | undefined): number { + if (header === undefined) return 0 + let tokens = 0 + for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message) + if (header.system !== undefined) { + tokens += Math.ceil(header.system.length / this.charsPerToken) + ROLE_OVERHEAD + } + if (header.tools !== undefined && header.tools.length > 0) { + tokens += Math.ceil(JSON.stringify(header.tools).length / this.charsPerToken) + BLOCK_OVERHEAD + } + return tokens + } +} diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts new file mode 100644 index 0000000000..e9b7e812c1 --- /dev/null +++ b/packages/llm/token-meter/src/types.ts @@ -0,0 +1,101 @@ +/** + * Public configuration and measurement vocabulary for replay token metering. + * + * @module @deepseek-ai/dsh-token-meter/types + */ + +import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' + +/** Optional pricing fields for one configured model. */ +export interface ModelTokenMeterConfig { + /** Provider context-window capacity in tokens. Required for a custom model. */ + contextWindow?: number + /** Heuristic text density in characters per token. Defaults to `4`. */ + charsPerToken?: number +} + +/** Token-meter plugin configuration. */ +export interface TokenMeterConfig { + /** Built-in field overrides and custom model profiles, keyed by routed model name. */ + models?: Record +} + +/** The baseline from which a signed surface delta produces current pressure. */ +export type TokenMeasurementBaseline = + | { readonly kind: 'none'; readonly tokens: 0 } + | { readonly kind: 'estimated'; readonly tokens: number } + | { readonly kind: 'usage'; readonly tokens: number; readonly usage: Readonly } + +/** Detached immutable scalar pressure at one consumed session-log revision. */ +export interface TokenMeasurement { + /** Model profile used for every heuristic component. */ + readonly model: string + /** Number of durable events consumed; equal to the next unread event seq. */ + readonly logRevision: number + /** Provider or heuristic anchor used for this measurement. */ + readonly baseline: TokenMeasurementBaseline + /** Signed repricing of current surface content relative to the baseline anchor. */ + readonly surfaceDeltaTokens: number + /** Non-negative current request-and-response pressure. */ + readonly totalTokens: number +} + +/** One token-priced node in the current ordered session surface. */ +export interface TokenSurfaceNode { + /** Durable sequence number of the surface event. */ + readonly seq: number + /** Heuristic tokens for the exact message projected by this node. */ + readonly tokens: number +} + +/** Detached immutable priced surface at one consumed session-log revision. */ +export interface TokenSurfaceMeasurement { + /** Model profile used to price every node. */ + readonly model: string + /** Number of durable events consumed; equal to the next unread event seq. */ + readonly logRevision: number + /** Total heuristic tokens across the current surface. */ + readonly totalTokens: number + /** Current surface nodes in positional head-to-tail order. */ + readonly nodes: readonly TokenSurfaceNode[] +} + +/** A model-bound replay meter returned by {@link TokenMeterService.resolve}. */ +export interface ModelTokenMeter { + /** Routed model name bound to this handle. */ + readonly model: string + /** Provider context-window capacity in tokens. */ + readonly contextWindow: number + /** Heuristic text density in characters per token. */ + readonly charsPerToken: number + + /** + * Measure current request pressure through the session's durable tail. + * + * Provider usage is reused only when its routed model and canonical request + * envelope match `requestHeader`; otherwise the complete envelope and + * surface are heuristically repriced for this handle's model. + * + * @param session - session to replay through its current durable tail. + * @param requestHeader - optional effective request envelope replacing the latest logged header. + * @returns a detached deeply immutable pressure measurement. + */ + measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement + + /** + * Price the current surface for retention and replacement decisions. + * + * @param session - session to replay through its current durable tail. + * @returns a detached deeply immutable positional surface measurement. + */ + measureSurface(session: Session): TokenSurfaceMeasurement + + /** + * Heuristically price one model-visible message. + * + * @param message - message to price without mutation. + * @returns content and role-framing tokens under this model profile. + */ + estimateMessage(message: Message): number +} diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts new file mode 100644 index 0000000000..1012d5c15e --- /dev/null +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -0,0 +1,603 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' +import type { EpochHeader } from '@deepseek-ai/dsh-session' +import TokenMeterService, { + TOKEN_METER_INVALID_CONFIG, + TOKEN_METER_MODEL_UNCONFIGURED, + TokenMeterError, +} from '@deepseek-ai/dsh-token-meter' +import type { ModelTokenMeter, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' + +function header(model: string, extras: Omit = {}): EpochHeader { + return canonicalHeader({ config: { model }, ...extras }) +} + +function textMessage(text: string, role: Message['role'] = 'user'): Message { + return { role, content: [{ type: 'text', text }] } +} + +function appendHeader(session: Session, value: EpochHeader): void { + session.append('request/header', { header: value, reason: 'initial' }) +} + +interface SuccessfulCallOptions { + turn?: number + step?: number + providerText?: string + durableText?: string + usage?: TokenUsage + provenance?: 'exact' | 'empty' | 'absent' +} + +function appendSuccessfulCall( + session: Session, + value: EpochHeader, + options: SuccessfulCallOptions = {}, +): void { + const turn = options.turn ?? 1 + const step = options.step ?? 1 + const providerText = options.providerText ?? 'provider answer' + const durableText = options.durableText ?? providerText + const provenance = options.provenance ?? 'exact' + session.append('step/start', { turn, step }) + appendHeader(session, value) + + const sources: number[] = [] + if (provenance === 'exact') { + const chunks = [ + { type: 'block-start' as const, index: 0, blockType: 'text' as const }, + { type: 'text-delta' as const, index: 0, text: providerText }, + { type: 'block-end' as const, index: 0, block: { type: 'text' as const, text: providerText } }, + ...options.usage === undefined ? [] : [{ type: 'usage' as const, usage: options.usage }], + { type: 'finish' as const, reason: { kind: 'stop' as const } }, + ] + for (const chunk of chunks) { + sources.push(session.append('assistant/chunk', { turn, step, chunk }).seq) + } + } + + const intent = provenance === 'absent' + ? { surfaceOp: 'append' as const } + : { surfaceOp: 'append' as const, sourceEventSeqs: provenance === 'empty' ? [] : sources } + session.append('assistant/message', { + turn, + step, + content: durableText.length === 0 ? [] : [{ type: 'text', text: durableText }], + ...options.usage === undefined ? {} : { usage: options.usage }, + }, intent) + session.append('step/end', { turn, step }) +} + +function meter(config: TokenMeterConfig = {}): TokenMeterService { + return new TokenMeterService(new Context(), config) +} + +describe('TokenMeterService configuration and registration', () => { + it('provides immutable zero-config DeepSeek profiles', () => { + const service = meter() + expect(service.resolve('deepseek-v4-flash')).toMatchObject({ + model: 'deepseek-v4-flash', + contextWindow: 128_000, + charsPerToken: 4, + }) + expect(service.resolve('deepseek-v4-pro')).toMatchObject({ + model: 'deepseek-v4-pro', + contextWindow: 128_000, + charsPerToken: 4, + }) + }) + + it('merges built-in overrides field-wise and defaults custom density', () => { + const service = meter({ + models: { + 'deepseek-v4-flash': { charsPerToken: 2 }, + custom: { contextWindow: 32_000 }, + }, + }) + expect(service.resolve('deepseek-v4-flash')).toMatchObject({ contextWindow: 128_000, charsPerToken: 2 }) + expect(service.resolve('deepseek-v4-pro')).toMatchObject({ contextWindow: 128_000, charsPerToken: 4 }) + expect(service.resolve('custom')).toMatchObject({ contextWindow: 32_000, charsPerToken: 4 }) + }) + + it('throws a typed exact-code error for unknown models', () => { + const service = meter() + let thrown: unknown + try { + service.resolve('unconfigured-model') + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(TokenMeterError) + expect(thrown).toMatchObject({ + code: TOKEN_METER_MODEL_UNCONFIGURED, + model: 'unconfigured-model', + }) + expect((thrown as Error).message).toContain('unconfigured-model') + }) + + it.each([ + [{ models: null }, /models must be an object/], + [{ models: [] }, /models must be an object/], + [{ models: { custom: {} } }, /requires contextWindow/], + [{ models: { '': { contextWindow: 1 } } }, /must not be empty/], + [{ models: { custom: { contextWindow: 0 } } }, /positive integer/], + [{ models: { custom: { contextWindow: 1.5 } } }, /positive integer/], + [{ models: { custom: { contextWindow: 1, charsPerToken: 0 } } }, /positive finite/], + [{ models: { custom: { contextWindow: 1, charsPerToken: Number.NaN } } }, /positive finite/], + [{ models: { custom: null } }, /must be an object/], + [{ models: { custom: [] } }, /must be an object/], + ] as unknown as Array<[TokenMeterConfig, RegExp]>)('rejects invalid profile config %#', (config, pattern) => { + let thrown: unknown + try { + meter(config) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(TokenMeterError) + expect(thrown).toMatchObject({ code: TOKEN_METER_INVALID_CONFIG }) + expect((thrown as Error).message).toMatch(pattern) + }) + + it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TokenMeterService) + expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeterService) + await fiber.dispose() + expect(ctx.get('tokenMeter')).toBeUndefined() + }) +}) + +describe('ModelTokenMeter pricing', () => { + it('prices every built-in content shape and merge-extended blocks', () => { + const handle = meter({ models: { custom: { contextWindow: 100, charsPerToken: 2 } } }).resolve('custom') + const blocks: ContentBlock[] = [ + { type: 'text', text: 'abcd' }, + { type: 'reasoning', text: 'ab' }, + { type: 'tool-call', id: CallId('c'), name: 'read', arguments: '{"x":1}' }, + { + type: 'tool-result', + toolCallId: CallId('c'), + content: [{ type: 'text', text: 'xy' }], + isError: false, + }, + { type: 'future-block', payload: 'abcd' } as unknown as ContentBlock, + ] + const estimated = handle.estimateMessage({ role: 'assistant', content: blocks }) + expect(estimated).toBeGreaterThan(30) + expect(handle.estimateMessage(textMessage('abcd'))).toBe(10) + }) + + it('returns a detached deeply immutable empty measurement', () => { + const handle = meter().resolve('deepseek-v4-flash') + const session = new Session(SessionId('empty')) + const result = handle.measure(session) + expect(result).toEqual({ + model: 'deepseek-v4-flash', + logRevision: 0, + baseline: { kind: 'none', tokens: 0 }, + surfaceDeltaTokens: 0, + totalTokens: 0, + }) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.baseline)).toBe(true) + expect(() => { + ;(result as { totalTokens: number }).totalTokens = 1 + }).toThrow(TypeError) + }) + + it('keeps earlier scalar and surface snapshots detached from later replay', () => { + const handle = meter().resolve('deepseek-v4-flash') + const session = new Session(SessionId('detached')) + session.append('user/message', { + content: [{ type: 'text', text: 'first' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const scalar = handle.measure(session) + const surface = handle.measureSurface(session) + const scalarCopy = structuredClone(scalar) + const surfaceCopy = structuredClone(surface) + + session.append('user/message', { + content: [{ type: 'text', text: 'second' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + expect(handle.measure(session).logRevision).toBe(2) + expect(handle.measureSurface(session).nodes).toHaveLength(2) + expect(scalar).toEqual(scalarCopy) + expect(surface).toEqual(surfaceCopy) + expect(scalar.logRevision).toBe(1) + expect(surface.nodes).toHaveLength(1) + }) + + it('prices header, prefix, tools, and surface when no reusable usage exists', () => { + const handle = meter().resolve('deepseek-v4-flash') + const session = new Session(SessionId('heuristic')) + session.append('user/message', { + content: [{ type: 'text', text: 'question' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendHeader(session, header('deepseek-v4-flash', { + system: 'system', + messagePrefix: [textMessage('prefix')], + tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }], + })) + const result = handle.measure(session) + expect(result.baseline.kind).toBe('estimated') + expect(result.totalTokens).toBeGreaterThan(handle.measureSurface(session).totalTokens) + expect(result.logRevision).toBe(session.events.length) + }) +}) + +describe('replay anchors and surface folds', () => { + const USAGE: TokenUsage = { + inputTokens: 20, + cacheReadTokens: 3, + cacheWriteTokens: 4, + outputTokens: 7, + reasoningTokens: 6, + } + + it('uses disjoint provider usage and signed durable-output rewrites', () => { + const handle = meter().resolve('deepseek-v4-flash') + const session = new Session(SessionId('usage')) + session.append('user/message', { + content: [{ type: 'text', text: 'before' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendSuccessfulCall(session, header('deepseek-v4-flash'), { + providerText: 'short', + durableText: 'a much longer rewritten durable assistant answer', + usage: USAGE, + }) + const result = handle.measure(session) + expect(result.baseline).toMatchObject({ kind: 'usage', tokens: 34, usage: USAGE }) + expect(result.surfaceDeltaTokens).toBeGreaterThan(0) + expect(result.totalTokens).toBe(34 + result.surfaceDeltaTokens) + expect(() => { + ;((result.baseline as { usage: { inputTokens: number } }).usage.inputTokens) = 1 + }).toThrow(TypeError) + }) + + it('uses an estimated anchor when provider usage is absent', () => { + const handle = meter().resolve('deepseek-v4-flash') + const session = new Session(SessionId('missing-usage')) + appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), { + providerText: 'provider', + durableText: 'rewritten', + }) + const anchored = handle.measure(session) + expect(anchored.baseline.kind).toBe('estimated') + expect(anchored.surfaceDeltaTokens).toBe(0) + session.append('user/message', { + content: [{ type: 'text', text: 'later' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const advanced = handle.measure(session) + expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0) + }) + + it('distinguishes explicit empty provenance from absent legacy provenance', () => { + const explicit = new Session(SessionId('explicit-empty')) + const legacy = new Session(SessionId('legacy-absent')) + appendSuccessfulCall(explicit, header('deepseek-v4-flash'), { + durableText: 'listener injected text', + providerText: '', + usage: USAGE, + provenance: 'empty', + }) + appendSuccessfulCall(legacy, header('deepseek-v4-flash'), { + durableText: 'listener injected text', + providerText: '', + usage: USAGE, + provenance: 'absent', + }) + const handle = meter().resolve('deepseek-v4-flash') + expect(handle.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0) + expect(handle.measure(legacy).surfaceDeltaTokens).toBe(0) + }) + + it('preserves one model anchor across another model success and reuses it after switching back', () => { + const service = meter({ + models: { + alpha: { contextWindow: 1000 }, + beta: { contextWindow: 1000, charsPerToken: 2 }, + }, + }) + const alpha = service.resolve('alpha') + const beta = service.resolve('beta') + const session = new Session(SessionId('switch')) + const alphaHeader = header('alpha', { system: 'same envelope' }) + appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' }) + expect(alpha.measure(session).baseline.kind).toBe('usage') + + appendSuccessfulCall(session, header('beta'), { + turn: 1, + step: 2, + usage: { inputTokens: 100, outputTokens: 50 }, + providerText: 'beta response', + }) + expect(alpha.measure(session).baseline.kind).toBe('estimated') + expect(beta.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 150 }) + + appendHeader(session, alphaHeader) + const switchedBack = alpha.measure(session) + expect(switchedBack.baseline).toMatchObject({ kind: 'usage', tokens: 34 }) + expect(switchedBack.surfaceDeltaTokens).toBeGreaterThan(0) + }) + + it('invalidates usage for any canonical envelope change or explicit override', () => { + const handle = meter().resolve('deepseek-v4-flash') + const session = new Session(SessionId('envelope')) + const anchoredHeader = header('deepseek-v4-flash', { system: 'one' }) + appendSuccessfulCall(session, anchoredHeader, { usage: USAGE }) + expect(handle.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage') + expect(handle.measure(session, header('deepseek-v4-flash', { system: 'two' })).baseline.kind) + .toBe('estimated') + expect(handle.measure(session, header('deepseek-v4-pro', { system: 'one' })).baseline.kind) + .toBe('estimated') + expect(handle.measure(session, { + ...anchoredHeader, + config: { ...anchoredHeader.config, temperature: 0.2 }, + }).baseline.kind).toBe('estimated') + expect(handle.measure(session, { + ...anchoredHeader, + messagePrefix: [textMessage('prefix')], + }).baseline.kind).toBe('estimated') + expect(handle.measure(session, { + ...anchoredHeader, + tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }], + }).baseline.kind).toBe('estimated') + }) + + it('folds valid header deltas into the effective envelope', () => { + const session = new Session(SessionId('header-delta')) + appendHeader(session, header('deepseek-v4-flash')) + session.append('request/header-delta', { config: { model: 'deepseek-v4-pro' } }) + const result = meter().resolve('deepseek-v4-flash').measure(session) + expect(result.baseline.kind).toBe('estimated') + expect(result.logRevision).toBe(2) + }) + + it('replays seeded append and replace operations with signed deltas', () => { + const service = meter() + const original = new Session(SessionId('surface-original')) + appendSuccessfulCall(original, header('deepseek-v4-flash'), { + usage: USAGE, + providerText: 'long provider answer '.repeat(100), + }) + original.append('user/message', { + content: [{ type: 'text', text: 'new tail' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const seeded = new Session(SessionId('surface-seeded'), original.events) + const handle = service.resolve('deepseek-v4-flash') + const before = handle.measureSurface(seeded) + const beforeScalar = handle.measure(seeded) + expect(before.nodes).toHaveLength(2) + expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0) + + const first = seeded.surface.nodes[0]!.seq + seeded.append('user/message', { + content: [{ type: 'text', text: 'replacement' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] }) + const after = handle.measureSurface(seeded) + const afterScalar = handle.measure(seeded) + expect(after.nodes).toHaveLength(2) + expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1) + expect(after.logRevision).toBe(seeded.events.length) + expect(Object.isFrozen(after.nodes)).toBe(true) + expect(Object.isFrozen(after.nodes[0])).toBe(true) + expect(afterScalar.surfaceDeltaTokens).toBeLessThan(0) + expect(before.nodes).toHaveLength(2) + expect(before.logRevision).toBe(original.events.length) + expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0) + }) + + it('prices an empty assistant surface anchor as zero', () => { + const session = new Session(SessionId('empty-assistant')) + appendSuccessfulCall(session, header('deepseek-v4-flash'), { + providerText: '', + durableText: '', + provenance: 'empty', + }) + const surface = meter().resolve('deepseek-v4-flash').measureSurface(session) + const assistant = session.events.find(event => event.type === 'assistant/message')! + expect(surface.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }]) + expect(surface.totalTokens).toBe(0) + }) +}) + +describe('malformed replay and listener lifecycle', () => { + function expectRepeatedFailure(handle: ModelTokenMeter, session: Session, pattern: RegExp): void { + expect(() => handle.measure(session)).toThrow(pattern) + expect(() => handle.measure(session)).toThrow(pattern) + } + + it('rejects a header delta before any snapshot transactionally', () => { + const session = new Session(SessionId('bad-delta')) + session.append('request/header-delta', { config: { model: 'deepseek-v4-flash' } }) + expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no preceding header/) + }) + + it('rejects a matching-model assistant without its step boundary transactionally', () => { + const session = new Session(SessionId('bad-step')) + appendHeader(session, header('deepseek-v4-flash')) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'bad' }], + }, { surfaceOp: 'append', sourceEventSeqs: [] }) + expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no matching step\/start/) + }) + + it('clears completed step boundaries and rejects overlapping or late step events', () => { + const overlapping = new Session(SessionId('overlapping-step')) + overlapping.append('step/start', { turn: 1, step: 1 }) + overlapping.append('step/start', { turn: 1, step: 2 }) + expectRepeatedFailure( + meter().resolve('deepseek-v4-flash'), + overlapping, + /arrived before turn 1\/step 1 ended/, + ) + + const late = new Session(SessionId('late-assistant')) + late.append('step/start', { turn: 1, step: 1 }) + appendHeader(late, header('deepseek-v4-flash')) + late.append('step/end', { turn: 1, step: 1 }) + late.append('assistant/message', { + turn: 1, + step: 1, + content: [], + }, { surfaceOp: 'append', sourceEventSeqs: [] }) + expectRepeatedFailure( + meter().resolve('deepseek-v4-flash'), + late, + /no matching step\/start/, + ) + + const mismatchedEnd = new Session(SessionId('mismatched-end')) + mismatchedEnd.append('step/start', { turn: 1, step: 1 }) + mismatchedEnd.append('step/end', { turn: 1, step: 2 }) + expectRepeatedFailure( + meter().resolve('deepseek-v4-flash'), + mismatchedEnd, + /step\/end .* no matching step\/start/, + ) + }) + + it('rejects invalid assistant provenance', () => { + const cases: Array<{ + name: string + appendSource(session: Session): number[] + pattern: RegExp + }> = [ + { + name: 'non-chunk', + appendSource(session) { + return [session.append('user/message', { + content: [{ type: 'text', text: 'x' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }).seq] + }, + pattern: /is not assistant\/chunk/, + }, + { + name: 'wrong-step', + appendSource(session) { + return [session.append('assistant/chunk', { + turn: 1, + step: 2, + chunk: { type: 'finish', reason: { kind: 'stop' } }, + }).seq] + }, + pattern: /belongs to another step/, + }, + ] + for (const testCase of cases) { + const session = new Session(SessionId(`bad-source-${testCase.name}`)) + session.append('step/start', { turn: 1, step: 1 }) + appendHeader(session, header('deepseek-v4-flash')) + const sourceEventSeqs = testCase.appendSource(session) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'bad' }], + usage: { inputTokens: 1, outputTokens: 1 }, + }, { surfaceOp: 'append', sourceEventSeqs }) + expect(() => meter().resolve('deepseek-v4-flash').measure(session)).toThrow(testCase.pattern) + } + }) + + it('rejects repeated and non-earlier assistant provenance', () => { + const duplicate = new Session(SessionId('duplicate-source')) + duplicate.append('step/start', { turn: 1, step: 1 }) + appendHeader(duplicate, header('deepseek-v4-flash')) + const source = duplicate.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'finish', reason: { kind: 'stop' } }, + }).seq + duplicate.append('assistant/message', { + turn: 1, + step: 1, + content: [], + usage: { inputTokens: 1, outputTokens: 0 }, + }, { surfaceOp: 'append', sourceEventSeqs: [source, source] }) + expect(() => meter().resolve('deepseek-v4-flash').measure(duplicate)).toThrow(/repeats source seq/) + + const future = new Session(SessionId('future-source')) + future.append('step/start', { turn: 1, step: 1 }) + appendHeader(future, header('deepseek-v4-flash')) + future.append('assistant/message', { + turn: 1, + step: 1, + content: [], + usage: { inputTokens: 1, outputTokens: 0 }, + }, { surfaceOp: 'append', sourceEventSeqs: [99] }) + expect(() => meter().resolve('deepseek-v4-flash').measure(future)).toThrow(/is not earlier/) + }) + + it('does not partially apply a malformed assistant replacement', () => { + const session = new Session(SessionId('transactional-replace')) + session.append('user/message', { + content: [{ type: 'text', text: 'head' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendHeader(session, header('deepseek-v4-flash')) + const head = session.events[0]!.seq + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'replacement' }], + }, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] }) + expectRepeatedFailure( + meter().resolve('deepseek-v4-flash'), + session, + /no matching step\/start/, + ) + }) + + it('rejects corrupt replacement ranges without advancing the replay cursor', () => { + const session = new Session(SessionId('bad-replace')) + session.append('user/message', { + content: [{ type: 'text', text: 'head' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('user/message', { + content: [{ type: 'text', text: 'bad' }], + source: { kind: 'user' }, + }, { surfaceOp: { op: 'replace', start: 99, end: 99 }, sourceEventSeqs: [0] }) + expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /invalid current range/) + }) + + it('handles earlier-reader catch-up, eager observation, and service reload', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let handle: ModelTokenMeter | undefined + const revisions: number[] = [] + ctx.on('session/event', (session) => { + if (handle !== undefined) revisions.push(handle.measure(session).logRevision) + }) + const firstFiber = await ctx.plugin(TokenMeterService) + handle = ctx.tokenMeter.resolve('deepseek-v4-flash') + const session = ctx.sessions.create(SessionId('listener-order')) + handle.measure(session) + session.append('user/message', { + content: [{ type: 'text', text: 'one' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + expect(revisions).toEqual([1]) + expect(handle.measure(session).logRevision).toBe(1) + + await firstFiber.dispose() + const secondFiber = await ctx.plugin(TokenMeterService) + handle = ctx.tokenMeter.resolve('deepseek-v4-flash') + expect(handle.measure(session).logRevision).toBe(1) + await secondFiber.dispose() + }) +}) diff --git a/packages/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json new file mode 100644 index 0000000000..5e1604e02f --- /dev/null +++ b/packages/llm/token-meter/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 54fb26b333..49974c94dd 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -32,6 +32,7 @@ Session log (per session): - **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. - **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. - **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal). +- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance. Agent status (per agent): diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 8da5429b06..7e39564227 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -118,8 +118,8 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr } } if (se.sourceEventSeqs !== undefined) { - if (se.sourceEventSeqs.length === 0) { - throw new InvariantError('sourceEventSeqs must not be empty when present') + if (se.sourceEventSeqs.length === 0 && event.type !== 'assistant/message') { + throw new InvariantError('sourceEventSeqs must not be empty except on assistant/message') } const unique = new Set(se.sourceEventSeqs) if (unique.size !== se.sourceEventSeqs.length) { diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 0212501114..ce535262ba 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -487,13 +487,17 @@ describe('surface invariants', () => { // no throw — well-formed replace op }) - it('rejects empty sourceEventSeqs', async () => { + it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => { const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] }) - }).toThrow(InvariantError) + }).not.toThrow() + expect(() => { + session.append('user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append', sourceEventSeqs: [] }) + }).toThrow(/must not be empty except on assistant\/message/) }) it('rejects duplicate sourceEventSeqs', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b40b8e69db..46bf3eba6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,7 +217,17 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/compact/compact-basic: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -239,12 +249,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) packages/context/time-context: dependencies: @@ -724,6 +737,22 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/llm/token-meter: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/sandbox/sandbox: devDependencies: '@deepseek-ai/dsh-llm': @@ -1856,6 +1885,9 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../packages/timeout/timeout-policy + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../packages/llm/token-meter '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../../packages/ui/tool-ask-user diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index cff5eda404..049db0f57c 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-jsonrpc": "workspace:^", "@deepseek-ai/dsh-jsonrpc-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a6660f7cd3..71fe28fbe0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -84,6 +84,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'compact-basic'], note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.', }, + { + key: 'tokenMeter', + pkg: 'token-meter', + title: 'Replay token measurement', + mode: 'core', + consumers: ['compact-basic'], + note: 'Owns isolated per-model/session replay folds; pressure consumers share immutable revisioned measurements.', + }, { key: 'sessions', pkg: 'session', @@ -823,6 +831,8 @@ function renderLifecycle(): string { ` Driver-->>SDK: ${mermaidCode('agent/status')} idle`, '```', '', + 'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.', + '', 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.', '', ...maintenanceFooter(maintenance), diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7b83f1004e..75f0e9bf12 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -30,6 +30,10 @@ { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, + { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" }, + { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceNode", "source": "packages/llm/token-meter/src/types.ts" }, + { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceMeasurement", "source": "packages/llm/token-meter/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index dc8d11a6c2..d4e5e00608 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, + 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 591955b260..fc5bbf9a5f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,6 +13,7 @@ { "path": "./packages/util/brand" }, { "path": "./packages/util/timeout" }, { "path": "./packages/llm/llm" }, + { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, { "path": "./packages/session-persistence/session-persistence" }, diff --git a/tsconfig.json b/tsconfig.json index e97a8295a5..b79d0e338c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ { "path": "./packages/util/brand" }, { "path": "./packages/util/timeout" }, { "path": "./packages/llm/llm" }, + { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, { "path": "./packages/session-persistence/session-persistence" }, 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 12/27] 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 99dccf559ade20054ab041143d0c6e594904b159 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 15:13:57 +0800 Subject: [PATCH 13/27] fix(compact): preserve replay ownership (PR2 round 2) --- docs/architecture.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 2 +- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/src/index.ts | 10 ++- .../compact-basic/tests/compact-basic.spec.ts | 20 ++++++ packages/compact/compact/README.md | 4 +- packages/compact/compact/src/index.ts | 12 ++-- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/loop.ts | 61 +++++++++++++---- .../tests/contract-regressions.spec.ts | 68 ++++++++++++++++++- 10 files changed, 154 insertions(+), 29 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 75a03c474c..1fa0ee377c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,7 +81,7 @@ forever: agent/request (config only) -> log request/header -> llm/stream (frozen) 'assistant/chunk' agent/step-result - 'assistant/message' + 'assistant/message' (transformed content, or an empty successful-call anchor if step-result rejects) each tool call: 'tool/call' tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result 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 d5d254cf4a..42b93f1225 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 @@ -30,7 +30,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the standalone service lets multiple consumers share one model/session replay fold. -`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options. +`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options. ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 435e14d875..c9264e2e99 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -13,7 +13,7 @@ This backend owns the compaction policy: - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. -- **Lifecycle** — `compactRegion()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. +- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged. `summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f8a9060aa9..cd4ea5d1d5 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -159,11 +159,12 @@ export class BasicCompactService extends CompactService { /** * Compact one inclusive positional surface range using the effective - * conversation model for all retention and shrink pricing. - * @param session - session whose surface is mutated. + * conversation model for all retention and shrink pricing. Reject an agent + * that does not own the exact target before any resolution or mutation. + * @param session - session whose surface is mutated; must equal `agent.session`. * @param start - inclusive first surface-node seq. * @param end - inclusive last surface-node seq. - * @param agent - agent used by the summarizer and model resolver. + * @param agent - owner of the target session, used by the summarizer and model resolver. * @param signal - optional summarization cancellation signal. * @returns the successful durable compaction result. */ @@ -174,6 +175,9 @@ export class BasicCompactService extends CompactService { agent: Agent, signal?: AbortSignal, ): Promise { + if (session !== agent.session) { + throw new Error('compactRegion: agent.session must be the exact target session') + } const model = effectiveModel(agent) if (model === undefined || model.length === 0) { throw new Error('compactRegion: no routed or configured conversation model is available for token pricing') diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 4feb981237..98b4b2f15b 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -361,6 +361,26 @@ describe('pressure measurement and retention', () => { }) describe('compaction region transaction', () => { + it('rejects an agent that does not own the exact target session before mutation', async () => { + const compact = service() + const target = conversation(2) + const owner = conversation(1) + const targetEvents = [...target.events] + const ownerEvents = [...owner.events] + const nodes = target.surface.nodes + + await expect(compact.compactRegion( + target, + nodes[0]!.seq, + nodes[1]!.seq, + agent(owner), + )).rejects.toThrow('compactRegion: agent.session must be the exact target session') + + expect(target.events).toEqual(targetEvents) + expect(owner.events).toEqual(ownerEvents) + expect(compact.calls).toEqual([]) + }) + it('lands a framed, replayable checkpoint with exact pricing provenance', async () => { const compact = service() const session = conversation(3) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index bbaeff0b17..89aacbb09b 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -19,9 +19,9 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev | Member | Semantics | |---|---| | `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | -| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. The agent must own the exact target (`session === agent.session`); a backend rejects mismatch before model resolution, lock acquisition, summarization, or log mutation. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | -`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. +`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. ## Tool-pairing boundaries diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 6d32ad0159..7361d38ef3 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -67,17 +67,19 @@ export abstract class CompactService extends Service { * `start` and `end` name an inclusive span by surface position, not numeric seq * order; replacements can make visible seqs non-monotonic. Both edges must be * balanced so assistant tool calls remain paired with their results. A model- - * backed implementation forwards cancellation and rejects active, missing, - * reversed, or unbalanced ranges. + * backed implementation forwards cancellation. The agent must own the exact + * target session object; implementations reject an ownership mismatch before + * model resolution, lock acquisition, summarization, or log mutation, and + * reject active, missing, reversed, or unbalanced ranges. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * - * @param session - session to mutate. + * @param session - session to mutate; must be identical to `agent.session`. * @param start - first surface seq, inclusive. * @param end - last surface seq, inclusive. - * @param agent - summarizer context. + * @param agent - owner of the target session and summarizer context. * @param signal - optional cancellation; model-backed implementations must forward it. - * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @throws when the agent does not own `session`, compaction is active, or the range is missing, reversed, or unbalanced. * @returns the replaced range and summary. */ abstract compactRegion( diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 99aef8b9cf..803a35fdc5 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -50,7 +50,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re The driver owns one agent for its lifetime. It records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. -Every provider call that reaches a successful finish appends one `assistant/message` completion anchor after `agent/step-result`, including content-less calls and `max-tokens` finishes. The anchor records exact chunk provenance (`[]` for a stream with no chunks) and usage when available; empty content stays out of derived message history while those replay facts remain durable. +Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index af42d8ff6a..b925162a22 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -6,7 +6,7 @@ */ import type { Context } from 'cordis' -import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' +import type { FinishReason, GenerateOptions, LlmCallConfig, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' @@ -527,29 +527,26 @@ async function runStep( 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))) - // Every successful call records its completion anchor. Empty content is - // skipped by deriveMessages(), while exact chunk provenance lets replay - // distinguish a known empty provider stream from unrecorded provenance. - session.append( - 'assistant/message', - { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + message = withoutToolCalls(await processStepResult( + events, session, turn, step, message, assembler.usage, chunkSeqs, + )) + appendAssistantCompletion( + session, turn, step, message.content, assembler.usage, chunkSeqs, ) return { hadToolCalls: false, finish: assembler.finish } } // 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)) + message = await processStepResult( + events, session, turn, step, message, assembler.usage, chunkSeqs, + ) // Every successful call records its completion anchor. A present empty // source set means the provider stream was known to contain no chunks; // omission remains the conservative legacy/unrecorded representation. - session.append( - 'assistant/message', - { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + appendAssistantCompletion( + session, turn, step, message.content, assembler.usage, chunkSeqs, ) // Tool execution stays sequential; recheck abort around each normalized result. @@ -601,6 +598,42 @@ async function runStep( return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish } } +/** Append the single durable completion anchor for one successful provider call. */ +function appendAssistantCompletion( + session: Session, + turn: number, + step: number, + content: Message['content'], + usage: TokenUsage | undefined, + sourceEventSeqs: number[], +): void { + session.append( + 'assistant/message', + { turn, step, content, ...(usage ? { usage } : {}) }, + { surfaceOp: 'append', sourceEventSeqs }, + ) +} + +/** Preserve successful-call accounting without retaining output that result processing rejected. */ +async function processStepResult( + events: AgentEventDispatch, + session: Session, + turn: number, + step: number, + message: Message, + usage: TokenUsage | undefined, + sourceEventSeqs: number[], +): Promise { + try { + return await events.waterfall( + 'agent/step-result', turn, step, message, () => Promise.resolve(message), + ) + } catch (error: unknown) { + appendAssistantCompletion(session, turn, step, [], usage, sourceEventSeqs) + throw error + } +} + function withoutToolCalls(message: Message): Message { return { ...message, content: message.content.filter(block => block.type !== 'tool-call') } } diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 4b2d9df1d3..dd5d2cdf4d 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -8,7 +8,7 @@ import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/ import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' -import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */ @@ -89,6 +89,72 @@ describe('session log records what agent/step-result actually produced', () => { }) }) +describe('successful provider completion survives agent/step-result failure', () => { + async function expectContentlessCompletionAnchor( + response: StreamChunk[], + id: string, + providerText: string, + ): Promise { + const adapter = new MockAdapter([response]) + const ctx = await harness(adapter) + await ctx.plugin(Invariants) + const agent = ctx.agentLoop.create(AgentId(id), { model: 'mock' }) + const failure = new Error(`${id} result processing failed`) + const reported: Error[] = [] + + ctx.on('agent/step-result', async () => { + throw failure + }) + ctx.on('agent/error', (subject, _turn, _step, error) => { + if (subject === agent) reported.push(error) + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + const chunks = events.filter(event => event.type === 'assistant/chunk') + const completions = events.filter(event => event.type === 'assistant/message') + expect(completions).toHaveLength(1) + expect(completions[0]?.type === 'assistant/message' && completions[0].data).toEqual({ + turn: 1, + step: 1, + content: [], + usage: { inputTokens: 10, outputTokens: providerText.length }, + }) + expect(completions[0]?.sourceEventSeqs).toEqual(chunks.map(event => event.seq)) + expect(agent.session.deriveMessages()).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'go' }] }, + ]) + expect(reported).toHaveLength(1) + expect(reported[0]).toBe(failure) + const turnEnd = events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ + kind: 'error', + step: 1, + message: failure.message, + }) + } + + it('records one content-less anchor when ordinary stop result processing rejects', async () => { + const providerText = 'ordinary provider output' + await expectContentlessCompletionAnchor( + textResponse(providerText), + 'a-step-result-stop-failure', + providerText, + ) + }) + + it('records one content-less anchor when max-token result processing rejects', async () => { + const providerText = 'truncated provider output' + await expectContentlessCompletionAnchor( + maxTokensResponse(providerText), + 'a-step-result-max-token-failure', + providerText, + ) + }) +}) + describe('abort during tool execution ends the turn', () => { it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { const adapter = new MockAdapter([ 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 14/27] 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 7250aaea7eabbe4de1a32075f4f785870d38e5c2 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 10:07:11 +0800 Subject: [PATCH 15/27] fix(sdk): compose token meter for compact projects --- docs/architecture.md | 6 +++--- packages/sdk/helper/package.json | 1 - pnpm-lock.yaml | 32 +++++++++++++++----------------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b5dc852a6b..63a5f07a61 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,7 +24,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | ctx key | Package family | Role | |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | -| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware per-model request and surface pressure | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware request/surface pressure per model | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | @@ -82,7 +82,7 @@ forever: agent/request (config only) -> log request/header -> llm/stream (frozen) 'assistant/chunk' agent/step-result - 'assistant/message' (transformed content, or an empty successful-call anchor if step-result rejects) + 'assistant/message' (transformed content or empty success anchor after step-result rejection) each tool call: 'tool/call' tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result @@ -126,7 +126,7 @@ Durability is a plugin concern. Persistence backends buffer synchronous `session ### Model Content -Messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`). The union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types are coordinated across adapters, UI bridges, token metering, and persistence, so block types remain a repo-wide contract. Replay measurement types are cataloged in [token-meter.md](core-data-structures/token-meter.md). +Messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`). The union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types require coordinated adapter, UI, token-meter, and persistence changes; replay measurement types live in [token-meter.md](core-data-structures/token-meter.md). Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAssembler` as the shared chunk-to-block assembler. The loop logs raw chunks while assembling them for dispatch. `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register with `ctx.llm.registerAdapter(models, adapter)`. StreamChunk conventions live in [llm-streaming.md](core-data-structures/llm-streaming.md). diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index 1452a1dbee..8d95dafe66 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -33,7 +33,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-hooks-claude": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0440d83eb0..334a95346d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -832,7 +832,7 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -953,9 +953,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-compact-basic': - specifier: workspace:^ - version: link:../../compact/compact-basic '@deepseek-ai/dsh-hooks-claude': specifier: workspace:^ version: link:../../hooks/hooks-claude @@ -3091,6 +3088,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -5557,11 +5558,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - unbash@3.0.0: resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} engines: {node: '>=14'} @@ -6169,11 +6165,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -6331,12 +6327,14 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@google/genai@1.52.0': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 protobufjs: 7.6.4 ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -6595,6 +6593,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -7959,8 +7960,6 @@ snapshots: neo-async: 2.6.2 source-map: 0.6.1 wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 has-flag@4.0.0: {} @@ -8066,6 +8065,8 @@ snapshots: jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 jiti@2.7.0: {} @@ -9306,9 +9307,6 @@ snapshots: typescript@6.0.3: {} - uglify-js@3.19.3: - optional: true - unbash@3.0.0: {} unconfig-core@7.5.0: From 4bf36e4dd153e97d47118881e96134f494da4671 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 10:29:08 +0800 Subject: [PATCH 16/27] docs: condense tool-context lifecycle contract --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index ce3f4898a2..2637f09f71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,7 @@ forever: The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). -Context that arrives while the current tool-call batch executes—including asynchronous `agent.inject()` notices and post-tool `additionalContext`—waits until execution settles and lands after every recorded result; successful batches keep call/result adjacency stable, while interrupted batches drain accepted context before the turn closes. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +Context accepted during tool execution—including async `agent.inject()` notices and post-tool `additionalContext`—waits for settlement, then follows every recorded result. Successful batches preserve call/result adjacency; interrupted batches drain that context before turn closure. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. ### Failure Boundaries 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 17/27] 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 From 5c243e8a8dfda41e7d17b98a77a84046de237434 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 12:58:07 +0800 Subject: [PATCH 18/27] refactor(token-meter): simplify singleton service (round 1) --- docs/architecture.md | 2 +- docs/capability-seams.md | 2 +- docs/config-catalog.md | 28 +- docs/cordis-catalog/services.md | 10 +- docs/core-data-structures/compaction.md | 2 +- docs/core-data-structures/token-meter.md | 6 +- ...07-15-replay-token-meter-service.i18n.yaml | 4 +- .../2026-07-15-replay-token-meter-service.md | 32 +- ...026-07-15-replay-token-meter-service.zh.md | 44 +- .../2026-06-18-compaction-capability-seam.md | 4 +- examples/coding-agent/cordis.yml | 4 +- examples/coding-agent/tests/compaction.e2e.ts | 9 +- examples/coding-agent/tests/harness.ts | 4 +- packages/compact/compact-basic/README.md | 12 +- .../compact/compact-basic/src/automatic.ts | 8 - packages/compact/compact-basic/src/config.ts | 89 +-- packages/compact/compact-basic/src/index.ts | 58 +- packages/compact/compact-basic/src/region.ts | 4 +- packages/compact/compact-basic/src/types.ts | 25 +- .../compact-basic/tests/compact-basic.spec.ts | 169 +++--- .../tests/compact-loop-repro.spec.ts | 11 +- .../tests/loader-composition.spec.ts | 5 +- .../cordis/tool-cordis/src/api-catalog.ts | 14 +- packages/llm/README.md | 2 +- packages/llm/token-meter/README.md | 29 +- packages/llm/token-meter/package.json | 2 +- packages/llm/token-meter/src/index.ts | 510 +++++++++++++----- packages/llm/token-meter/src/replay.ts | 367 ------------- packages/llm/token-meter/src/types.ts | 58 +- .../llm/token-meter/tests/token-meter.spec.ts | 214 +++----- scripts/gen-doc-graphs.ts | 2 +- 31 files changed, 653 insertions(+), 1077 deletions(-) delete mode 100644 packages/llm/token-meter/src/replay.ts diff --git a/docs/architecture.md b/docs/architecture.md index 63a5f07a61..9c31495f87 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,7 +24,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | ctx key | Package family | Role | |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | -| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware request/surface pressure per model | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 398c265cf0..9d8c612bdb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -194,7 +194,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | -| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-model/session replay folds; pressure consumers share immutable revisioned measurements. | +| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 23dedc5a8b..3ef87c2d44 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -226,8 +226,10 @@ Requires: `llm` · `tokenMeter` ```ts config-catalog /** Basic compaction configuration; every common field has a deployment default. */ export interface BasicCompactConfig { - /** Field-wise pressure/retention overrides keyed by configured token-meter model name. */ - models?: Record + /** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */ + thresholdRatio?: number + /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + retainTokens?: number /** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */ summarizationModel?: string /** Provider generation cap for summarization. Defaults to `8192`. */ @@ -237,17 +239,9 @@ export interface BasicCompactConfig { /** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */ auto?: boolean } - -/** Optional pressure and retention policy for one metered model. */ -export interface ModelCompactConfig { - /** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */ - thresholdRatio?: number - /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ - retainTokens?: number -} ``` -Source: [`packages/compact/compact-basic/src/types.ts:16`](../packages/compact/compact-basic/src/types.ts) +Source: [`packages/compact/compact-basic/src/types.ts:8`](../packages/compact/compact-basic/src/types.ts) ## `@deepseek-ai/dsh-fs-local` @@ -875,20 +869,12 @@ Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/ti ```ts config-catalog /** Token-meter plugin configuration. */ export interface TokenMeterConfig { - /** Built-in field overrides and custom model profiles, keyed by routed model name. */ - models?: Record -} - -/** Optional pricing fields for one configured model. */ -export interface ModelTokenMeterConfig { - /** Provider context-window capacity in tokens. Required for a custom model. */ + /** Service-wide context-window capacity in tokens. Defaults to `128000`. */ contextWindow?: number - /** Heuristic text density in characters per token. Defaults to `4`. */ - charsPerToken?: number } ``` -Source: [`packages/llm/token-meter/src/types.ts:19`](../packages/llm/token-meter/src/types.ts) +Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts) ## `@deepseek-ai/dsh-tool-bash` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b4ce033f36..5805ae130d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -263,13 +263,17 @@ Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/ ## `ctx.tokenMeter` — `TokenMeterService` -Concrete registry and replay owner for all configured model meters. +Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog -resolve(model: string): ModelTokenMeter +measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement +measureSurface(session: Session): TokenSurfaceMeasurement +estimateMessage(message: Message): number ``` -Source: [`packages/llm/token-meter/src/index.ts:145`](../../packages/llm/token-meter/src/index.ts) +Types: [Message](../core-data-structures/core.md) + +Source: [`packages/llm/token-meter/src/index.ts:92`](../../packages/llm/token-meter/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 50a80208a9..3b861d5de4 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,7 +50,7 @@ interface CompactionResult { ## The service -`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. The seam owns no pricing API: `dsh-compact-basic` resolves the routed model through [`ctx.tokenMeter`](token-meter.md), whose model-bound handle owns estimation and replay, while the backend owns retention, event sequencing, and summarization. +`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. The seam owns no pricing API: [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details. diff --git a/docs/core-data-structures/token-meter.md b/docs/core-data-structures/token-meter.md index 98467f3715..eee01c27b8 100644 --- a/docs/core-data-structures/token-meter.md +++ b/docs/core-data-structures/token-meter.md @@ -8,8 +8,6 @@ Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter ```ts type-equiv interface TokenMeasurement { - /** Model profile used for every heuristic component. */ - readonly model: string /** Number of durable events consumed; equal to the next unread event seq. */ readonly logRevision: number /** Provider or heuristic anchor used for this measurement. */ @@ -21,7 +19,7 @@ interface TokenMeasurement { } ``` -`baseline.kind === 'usage'` means a successful provider call has the same model and canonical envelope. `estimated` means the meter repriced the complete envelope and surface. Signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching provider or estimated anchor. +`baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope. `estimated` means the service priced the complete envelope and surface with its fixed heuristic. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor. ## `TokenSurfaceNode` @@ -38,8 +36,6 @@ interface TokenSurfaceNode { ```ts type-equiv interface TokenSurfaceMeasurement { - /** Model profile used to price every node. */ - readonly model: string /** Number of durable events consumed; equal to the next unread event seq. */ readonly logRevision: number /** Total heuristic tokens across the current surface. */ diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index 99c6301f25..afd99ac686 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 4452c151e122c4a4ad72e3f0bc2616cd2fa28b9d -2026-07-15-replay-token-meter-service.zh.md: 23edc11ffd19b9cfaeb794f3608e9ced4e7dbb7b +2026-07-15-replay-token-meter-service.md: 981e789a51bbe7e2b09d47a76d71ac79fe14c999 +2026-07-15-replay-token-meter-service.zh.md: 85565b6f3db77ab81712f9c128e570c7a16bae8d diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md index 4452c151e1..981e789a51 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -6,52 +6,52 @@ English | [中文](2026-07-15-replay-token-meter-service.zh.md) ## Problem -Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of one model's window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse accounting from the wrong model. +Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of the configured context window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting. -Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch models, old logs can lack chunk provenance, and provider fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines exact anchors with conservative model-specific repricing and exposes the log revision consumed by each result. +Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch models, old logs can lack chunk provenance, and provider fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines the latest exact anchor with conservative heuristic repricing and exposes the log revision consumed by each result. ## Decision ### One concrete LLM-family service -`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. Its public entry point resolves an exact model name to a stable `ModelTokenMeter`; unknown names throw `TokenMeterError` with `TOKEN_METER_MODEL_UNCONFIGURED` instead of inheriting a universal window. +`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `contextWindow`, `measure(session, requestHeader?)`, `measureSurface(session)`, and `estimateMessage(message)`; consumers call the singleton service directly. -The built-in `deepseek-v4-flash` and `deepseek-v4-pro` profiles use a 128,000-token context window and four characters per estimated token. `models` overrides merge field-by-field. A custom name requires `contextWindow`, while `charsPerToken` defaults to four. Direct construction reports typed profile errors; Loader mounts first apply the package's Schemastery shape validation. +The service has one `contextWindow`, defaulting to 128,000 tokens and configurable as a positive integer. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, density settings, tokenizer backends, or language-specific strategies. -### Model-bound replay folds +### Per-session replay folds -Each model/session pair owns an isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical request headers and deltas, step boundaries, surface appends and replacements, assistant usage, and assistant-chunk provenance. A malformed next event fails transactionally and remains unread rather than partially mutating state. +Each session owns one isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical request headers and deltas, step boundaries, surface appends and replacements, assistant usage, and assistant-chunk provenance. A malformed next event fails transactionally and remains unread rather than partially mutating state. -`measure(session, requestHeader?)` returns scalar pressure. `measureSurface(session)` returns positional per-node prices for retention and replacement decisions. `estimateMessage(message)` applies the handle's profile without session state. Results are detached, deeply immutable snapshots carrying `logRevision`; a consumer compares scalar and surface revisions before making one decision. +`measure(session, requestHeader?)` returns scalar pressure. `measureSurface(session)` returns positional per-node prices for retention and replacement decisions. `estimateMessage(message)` applies the fixed heuristic without session state. Results are detached, deeply immutable snapshots carrying `logRevision`; a consumer compares scalar and surface revisions before making one decision. -Provider usage is reused only when the handle's model and canonical request envelope equal the successful-call anchor. Any system, prefix, tool, or call-config change causes complete repricing under the requested model. Surface changes remain a signed delta from a matching anchor, including negative values after a shrinking replacement. A success by another model changes the shared surface but never overwrites this model's anchor. +Provider usage is reused only when the measured canonical request envelope equals the latest successful-call anchor. Any model, system, prefix, tool, or call-config change causes complete heuristic repricing. Surface changes remain a signed delta from a matching anchor, including negative values after a shrinking replacement. A later successful request replaces the earlier anchor, including across model switches. Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reasoning is not added a second time. Every successful model call records an `assistant/message`, including content-less and max-token calls, with its exact earlier chunk seqs. An explicit empty provenance list means a known empty provider stream; absent legacy provenance conservatively treats the durable assistant output as provider output. ### Compact-basic consumes, but does not own, measurement -`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The conversation model's meter consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection. +`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The singleton service consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection. -Every metered model receives a compact policy with defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, summarization model `''`, maximum summary output `8192`, one extra compaction attempt, and automatic triggering enabled. Per-model compact overrides merge `thresholdRatio` and `retainTokens`; retention must remain below the resulting threshold. Empty summarization model resolves the latest logged routed model, then `AgentOptions.model`. +Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, summarization model `''`, maximum summary output `8192`, one extra compaction attempt, and automatic triggering enabled. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. Empty summarization model resolves the latest logged routed model, then `AgentOptions.model`. -The pre-step trigger measures a provisional envelope: the current prompt and prefix override logged values, while the latest logged header supplies model, tools, and other call config. A model-less router-only agent skips that provisional check because `agent/request` can route later; naming an unknown model remains an error. +The pre-step trigger measures a provisional envelope: the current prompt and prefix override logged values, while the latest logged header supplies model, tools, and other call config. A model-less router-only agent skips that provisional check because `agent/request` can route later; any routed model name can use the singleton estimator. ## Testing -Unit coverage pins profiles, field-wise overrides, custom and unknown models, envelope invalidation, model switching, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, immutable snapshots, listener ordering, reload, compact defaults, routing fallback, retention, convergence, and transaction rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order. +Unit coverage pins service configuration, fixed estimation, envelope invalidation, latest-anchor replacement across model switches, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, immutable snapshots, listener ordering, reload, compact defaults, routing fallback, retention, convergence, and transaction rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order. ## Alternatives considered - **Keep estimation inside `CompactService`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API. - **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration. -- **Give unknown models a 128,000-token fallback** — rejected because a plausible but wrong capacity can trigger destructive policy at the wrong point. Unknown routed names fail with their exact name. +- **Keep model-keyed windows and density profiles** — rejected because the deployment currently has one context policy and one estimator. Model registries, unknown-model failures, and configurable density add branches without a second behavior to select. - **Copy complete history into each scalar result** — rejected because below-threshold reads are common. Immutable revisioned scalars and a separate surface snapshot preserve consistency without an O(history) copy. -- **Treat provider usage as portable between models or envelopes** — rejected because tokenization, context capacity, tools, prefixes, and call config are model/request facts. Mismatch reprices the whole current request. +- **Treat provider usage as portable between envelopes** — rejected because model, tools, prefixes, and call config are request facts. Mismatch reprices the whole current request. ## Consequences - Token pressure has one replay-aware owner that compaction and future plugins can share. -- Defaults make the bundled DeepSeek composition usable with two zero-config plugin entries, while custom models must state the one fact that cannot be guessed safely: context capacity. -- Heuristic density and provider usage remain estimates of provider behavior. Maintainers must update built-in profiles and overflow wording as models evolve. +- The default makes the bundled composition usable with two zero-config plugin entries; deployments override one context capacity when needed. +- Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer. - Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure. - The pre-step compact integration can skip a router-only first check and can miss tool or routing changes applied later in request middleware. diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 23edc11ffd..85565b6f3d 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -1,4 +1,4 @@ -# RFC: 重放式 token 计量服务 +# RFC: 回放式 token 计量服务 Status: implemented @@ -6,52 +6,52 @@ Status: implemented ## 问题 -上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了某个模型多少上下文窗口?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现重放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方错误复用其他模型的核算结果。 +上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了已配置上下文窗口的多少容量?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 -提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换模型,旧日志可能缺少 chunk 来源,提供方字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把精确锚点与保守的逐模型重新定价结合起来,并公开每个结果已经消费的日志修订号。 +提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换模型,旧日志可能缺少分片来源,提供方字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。 ## 决策 ### 一个具体的 LLM 家族服务 -`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体 package,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。公开入口把精确模型名解析为稳定的 `ModelTokenMeter`;未知名称抛出带 `TOKEN_METER_MODEL_UNCONFIGURED` 的 `TokenMeterError`,而不是继承通用窗口。 +`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `contextWindow`、`measure(session, requestHeader?)`、`measureSurface(session)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 -内置的 `deepseek-v4-flash` 与 `deepseek-v4-pro` profile 都采用 128,000 token 上下文窗口,以及每 token 四个字符的估算密度。`models` 覆盖按字段合并。自定义名称必须提供 `contextWindow`,而 `charsPerToken` 默认为四。直接构造会报告类型化 profile 错误;Loader 挂载则先应用 package 的 Schemastery 形状校验。 +服务只有一个 `contextWindow`,默认值为 128,000 token,并允许配置为正整数。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、密度设置、分词器后端或语言专用策略。 -### 绑定模型的重放折叠 +### 逐会话回放折叠 -每个模型/会话对都有隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范请求头及其增量、步骤边界、表层追加与替换、assistant usage,以及 assistant chunk 来源。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。 +每个会话都有一个隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范请求头及其增量、步骤边界、表层追加与替换、assistant usage,以及 assistant 分片来源。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。 -`measure(session, requestHeader?)` 返回标量压力。`measureSurface(session)` 返回用于保留与替换决策的逐位置节点价格。`estimateMessage(message)` 不依赖会话状态,直接应用该 handle 的 profile。结果是分离且深度不可变的快照,并携带 `logRevision`;消费者在一次联合决策前比较标量与表层修订号。 +`measure(session, requestHeader?)` 返回标量压力。`measureSurface(session)` 返回用于保留与替换决策的逐位置节点价格。`estimateMessage(message)` 不依赖会话状态,直接应用固定启发式规则。结果是分离且深度不可变的快照,并携带 `logRevision`;消费方在一次联合决策前比较标量与表层修订号。 -只有当 handle 的模型与规范请求信封都等于成功调用锚点时,服务才复用提供方 usage。系统提示词、前缀、工具或调用配置任一变化都会在请求模型下重新定价完整当前请求。表层变化相对匹配锚点保留有符号增量,包括缩小替换后的负值。其他模型的成功调用会改变共享表层,但绝不会覆盖当前模型的锚点。 +只有当待计量的规范请求信封等于最近一次成功调用的锚点时,服务才复用提供方 usage。模型、系统提示词、前缀、工具或调用配置任一变化都会触发完整的启发式重新定价。表层变化相对匹配锚点保留有符号增量,包括缩小替换后的负值。后续成功请求会替换先前锚点,模型切换时也一样。 -Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 求和,不会再次加入推理计数。每次成功模型调用都会记录 `assistant/message`,包括无内容调用与达到 token 上限的调用,并带上精确的更早 chunk seq。显式空来源列表表示已知为空的提供方流;旧日志中缺失的来源则保守地把持久 assistant 输出视为提供方输出。 +Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 求和,不会再次加入推理计数。每次成功模型调用都会记录 `assistant/message`,包括无内容调用与达到 token 上限的调用,并带上精确的更早分片 seq。显式空来源列表表示已知为空的提供方流;旧日志中缺失的来源则保守地把持久 assistant 输出视为提供方输出。 ### compact-basic 消费计量,但不拥有计量 -`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。会话模型的 meter 一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝。 +`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。单例服务一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。 -每个已计量模型都会获得默认压缩策略:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、摘要模型 `''`、摘要最大输出 `8192`、一次额外压缩尝试,以及启用自动触发。逐模型压缩覆盖按字段合并 `thresholdRatio` 与 `retainTokens`;保留值必须小于最终阈值。空摘要模型先解析最近记录的实际路由模型,再使用 `AgentOptions.model`。 +压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、摘要模型 `''`、摘要最大输出 `8192`、一次额外压缩尝试,以及启用自动触发。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。空摘要模型先解析最近记录的实际路由模型,再使用 `AgentOptions.model`。 -pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日志值,最近记录的请求头提供模型、工具及其他调用配置。没有模型的纯路由 agent 会跳过该临时检查,因为 `agent/request` 仍可稍后路由;显式命名未知模型仍然报错。 +pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日志值,最近记录的请求头提供模型、工具及其他调用配置。没有模型的纯路由 agent(智能体)会跳过该临时检查,因为 `agent/request` 仍可稍后路由;任意路由模型名都可使用这个单例估算器。 ## 测试 -单元覆盖固定 profile、按字段覆盖、自定义与未知模型、信封失效、模型切换、有无 usage 的路径、种子追加/替换重放、有符号增量、来源模式、畸形边界、不可变快照、监听器顺序、重载、压缩默认值、路由回退、保留、收敛与事务回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic package 名称。 +单元覆盖固定服务配置、固定估算、信封失效、模型切换时替换最新锚点、有无 usage 的路径、种子追加/替换回放、有符号增量、来源模式、畸形边界、不可变快照、监听器顺序、重载、压缩默认值、路由回退、保留、收敛与事务回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic 包名称。 ## 考虑过的替代方案 -- **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费者与重放语义;它还会强迫每个压缩器暴露同一套无关 API。 -- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的 package 与配置。 -- **给未知模型提供 128,000 token 回退**——不予采纳,因为看似合理但错误的容量会在错误时点触发破坏性策略。未知路由名称会携带精确名称失败。 +- **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。 +- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的包与配置。 +- **保留模型键控的窗口与密度 profile**——不予采纳,因为当前部署只有一种上下文策略与一个估算器。模型注册表、未知模型错误和可配置密度只增加分支,却没有第二种行为可供选择。 - **在每个标量结果中复制完整历史**——不予采纳,因为低于阈值的读取很常见。不可变且带修订号的标量与独立表层快照,在不进行 O(history) 复制的情况下保持一致性。 -- **在模型或信封之间移用提供方 usage**——不予采纳,因为分词、上下文容量、工具、前缀与调用配置都是模型/请求事实。不匹配时会重新定价完整当前请求。 +- **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。 ## 后果 -- Token 压力拥有一个可供压缩与未来插件共享的重放感知所有者。 -- 默认值让内置 DeepSeek 组合只需两个零配置插件条目即可使用,而自定义模型必须声明唯一不能安全猜测的事实:上下文容量。 -- 启发式密度与提供方 usage 仍然只是提供方行为的估计。随着模型演进,维护者必须更新内置 profile 与溢出措辞。 -- 遇到畸形持久边界时,计量会明确失败。这会把损坏的重放转化为具名集成错误,而不是让压力静默漂移。 +- Token 压力拥有一个可供压缩与未来插件共享的回放感知所有者。 +- 默认值让内置组合只需两个零配置插件条目即可使用;部署需要时只覆盖一个上下文容量。 +- 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。 +- 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。 - pre-step 压缩集成可能跳过纯路由的首次检查,也可能错过请求中间件稍后应用的工具或路由变化。 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 42b93f1225..77f836e8e3 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 @@ -64,7 +64,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Approximate convergence invariant -`resolveConfig` supplies usable common defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization-model override, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. Optional per-model threshold/retention fields merge over those defaults and must name a configured meter profile; retained tokens must be below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If the compacted surface remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. +`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization-model override, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. Optional top-level `thresholdRatio` and `retainTokens` override the policy for the token meter's single context window; retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If the compacted surface remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary @@ -115,7 +115,7 @@ Two failure paths, both documented: - **`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-compact`** owns `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and resolves after-edges from its positional successor map instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. - **`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. -- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; bundled DeepSeek profiles and compact defaults make the pair usable without repeated numeric policy. +- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy. ## Testing diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 881399f64e..5a85c3488f 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -46,12 +46,12 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Replay-aware request pressure for the bundled DeepSeek model profiles. +# Replay-aware request pressure with one service-wide context window. - id: token-meter name: '@deepseek-ai/dsh-token-meter' # Summarize an older range when measured history approaches the context window. -# Built-in model policies provide the ordinary threshold and retained-tail defaults. +# Service-wide policy provides the ordinary threshold and retained-tail defaults. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index c2aa809327..aa9fda68e2 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -34,14 +34,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT, tokenMeter: { - models: { - 'deepseek-v4-flash': { contextWindow: 2000 }, - }, + contextWindow: 2000, }, compact: { - models: { - 'deepseek-v4-flash': { thresholdRatio: 0.5, retainTokens: 400 }, - }, + thresholdRatio: 0.5, + retainTokens: 400, summarizationModel: '', maxTokens: 1024, compactionRetries: 1, diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index 923b5efe84..cf79809f0c 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -48,7 +48,7 @@ export interface CodingHarnessOptions { * compaction plugin (the default suites run without it). */ compact?: BasicCompactConfig - /** Optional meter profiles loaded before compact-basic. */ + /** Optional token-meter capacity loaded before compact-basic. */ tokenMeter?: TokenMeterConfig } @@ -65,7 +65,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) // Compaction is opt-in: only the compaction e2e loads the reusable meter and - // backend, with a lowered profile window so a short real session crosses the threshold. + // backend, with a lower context window so a short real session crosses the threshold. if (options.compact !== undefined) { await ctx.plugin(TokenMeterService, options.tokenMeter) await ctx.plugin(BasicCompactService, options.compact) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index c9264e2e99..b166636a5f 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -8,7 +8,7 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: -- **Measurement** — the effective conversation model's `ModelTokenMeter` prices the provisional request envelope and current surface at one consumed-log revision. The current prompt and prefix override their logged values; the pre-step boundary reuses logged tools and call config. +- **Measurement** — the singleton `ctx.tokenMeter` prices the provisional request envelope and current surface at one consumed-log revision. The current prompt and prefix override their logged values; the pre-step boundary reuses logged tools and call config. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. @@ -16,16 +16,16 @@ This backend owns the compaction policy: - **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged. -`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`. +`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`. ## Config (`BasicCompactConfig`) -Every common setting is optional. Every model known to `ctx.tokenMeter` receives the default compact policy lazily; named overrides merge only the fields supplied and must name a configured meter profile. +Every setting is optional. The pressure and retention policy applies to the token meter's single context window. | Key | Required | Meaning | |---|---|---| -| `models..thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. | -| `models..retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. | +| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. | +| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. | | `summarizationModel` | no (default `''`) | Empty resolves the latest logged routed model, then `AgentOptions.model`. | | `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. | | `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. | @@ -116,7 +116,7 @@ Rules: ## Known Limitations and Deferred Work - **Pre-step sees a provisional request envelope** — the current prompt and prefix are exact, but routing and tool changes made later in `agent/request` are not logged yet. A router-only agent with no provisional model skips that check. -- **Meter accuracy follows the selected profile** — missing provider usage falls back to the token meter's configured character density and structural overhead. +- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization. - **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting. - **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds. - **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). diff --git a/packages/compact/compact-basic/src/automatic.ts b/packages/compact/compact-basic/src/automatic.ts index 504b0d8a9f..edb317d270 100644 --- a/packages/compact/compact-basic/src/automatic.ts +++ b/packages/compact/compact-basic/src/automatic.ts @@ -7,10 +7,6 @@ import type { Context } from 'cordis' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import type { Message } from '@deepseek-ai/dsh-llm' -import { - TOKEN_METER_MODEL_UNCONFIGURED, - TokenMeterError, -} from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' interface AutomaticCompactor { @@ -49,10 +45,6 @@ export function registerAutomaticCompaction( ) } } catch (error: unknown) { - // A named routed model without a meter profile is configuration failure, - // not an optional operational compaction miss. - if (error instanceof TokenMeterError - && error.code === TOKEN_METER_MODEL_UNCONFIGURED) throw error const message = error instanceof Error ? error.message : String(error) ctx.logger.warn(`compaction failed: ${message}; proceeding with full history`) } diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index 3169d7f5aa..da2cd7bea7 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -1,63 +1,49 @@ /** - * Runtime defaulting and per-model policy validation for compact-basic. + * Runtime defaulting and policy validation for compact-basic. * * @module @deepseek-ai/dsh-compact-basic/config */ import { deepFreeze } from '@deepseek-ai/dsh-llm' -import type { ModelTokenMeter, TokenMeterService } from '@deepseek-ai/dsh-token-meter' -import type { - BasicCompactConfig, - ModelCompactConfig, - ResolvedConfig, - ResolvedModelCompactConfig, -} from './types.ts' +import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' +import type { BasicCompactConfig, ResolvedConfig } from './types.ts' -/** Default request-pressure fraction for every metered model. */ +/** Default request-pressure fraction of the token meter's context window. */ const DEFAULT_THRESHOLD_RATIO = 0.8 -/** Default verbatim-tail fraction of a model's context window. */ +/** Default verbatim-tail fraction of the token meter's context window. */ const DEFAULT_RETAIN_RATIO = 0.16 /** - * Resolve common defaults and validate every named model override. + * Resolve defaults and validate the service-wide compaction policy. * @param config - raw compact-basic configuration. - * @param tokenMeter - owning meter service used to reject unknown override names. - * @returns a detached deeply immutable top-level configuration. + * @param tokenMeter - token meter supplying the context capacity. + * @returns a detached deeply immutable configuration. */ export function resolveConfig( config: BasicCompactConfig = {}, tokenMeter: TokenMeterService, ): ResolvedConfig { - const configuredModels: unknown = config.models - const models = configuredModels === undefined ? {} : configuredModels - if (typeof models !== 'object' || models === null || Array.isArray(models)) { - throw new Error('BasicCompactConfig: models must be an object') - } - - const detachedModels: Record = {} - for (const [model, override] of Object.entries(models as Record)) { - if (typeof override !== 'object' || override === null || Array.isArray(override)) { - throw new Error(`BasicCompactConfig: models.${model} must be an object`) - } - const meter = tokenMeter.resolve(model) - detachedModels[model] = { ...override as ModelCompactConfig } - resolveModelConfig({ - models: detachedModels, - summarizationModel: '', - maxTokens: 8192, - compactionRetries: 1, - auto: true, - }, meter) - } - + const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO + const retainTokens = config.retainTokens + ?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO) const resolved: ResolvedConfig = { - models: detachedModels, + thresholdRatio, + retainTokens, summarizationModel: config.summarizationModel ?? '', maxTokens: config.maxTokens ?? 8192, compactionRetries: config.compactionRetries ?? 1, auto: config.auto ?? true, } + + assertRatio('thresholdRatio', resolved.thresholdRatio) + assertNonNegativeInteger('retainTokens', resolved.retainTokens) + const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio) + if (resolved.retainTokens >= thresholdTokens) { + throw new Error( + `BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`, + ) + } assertPositiveInteger('maxTokens', resolved.maxTokens) assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) if (typeof resolved.summarizationModel !== 'string') { @@ -66,36 +52,7 @@ export function resolveConfig( if (typeof resolved.auto !== 'boolean') { throw new Error('BasicCompactConfig: auto must be a boolean') } - return deepFreeze(structuredClone(resolved)) -} - -/** - * Resolve one effective model's default policy plus optional field overrides. - * @param config - validated compact-basic configuration. - * @param meter - effective model's token-meter handle and context capacity. - * @returns a detached immutable model policy. - */ -export function resolveModelConfig( - config: ResolvedConfig, - meter: ModelTokenMeter, -): ResolvedModelCompactConfig { - const override = config.models[meter.model] - const thresholdRatio = override?.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO - const retainTokens = override?.retainTokens ?? Math.floor(meter.contextWindow * DEFAULT_RETAIN_RATIO) - assertRatio(`models.${meter.model}.thresholdRatio`, thresholdRatio) - assertNonNegativeInteger(`models.${meter.model}.retainTokens`, retainTokens) - const thresholdTokens = Math.floor(meter.contextWindow * thresholdRatio) - if (retainTokens >= thresholdTokens) { - throw new Error( - `BasicCompactConfig: models.${meter.model}.retainTokens (${retainTokens}) must be less than threshold tokens ${thresholdTokens}`, - ) - } - return deepFreeze({ - model: meter.model, - contextWindow: meter.contextWindow, - thresholdRatio, - retainTokens, - }) + return deepFreeze(resolved) } function assertPositiveInteger(name: string, value: number): void { diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index cd4ea5d1d5..16387c5e09 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -11,24 +11,20 @@ import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' -import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' import { registerAutomaticCompaction } from './automatic.ts' -import { resolveConfig, resolveModelConfig } from './config.ts' +import { resolveConfig } from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' import type { BasicCompactConfig, ResolvedConfig, - ResolvedModelCompactConfig, } from './types.ts' -export { resolveConfig, resolveModelConfig } from './config.ts' +export { resolveConfig } from './config.ts' export type { BasicCompactConfig, - ModelCompactConfig, ResolvedConfig, - ResolvedModelCompactConfig, } from './types.ts' /** Resolve the latest actual routed model, then the agent's configured fallback. */ @@ -61,28 +57,24 @@ function provisionalHeader( * retention, provenance, and summary-convergence pricing. * * `summarize()` is the sole subclass customization hook; the replay and durable - * mutation strategy stays fixed so every pricing decision uses one effective - * conversation-model meter. + * mutation strategy stays fixed so every pricing decision uses the singleton + * token meter. */ export class BasicCompactService extends CompactService { static inject = ['llm', 'tokenMeter'] static Config: z = z.object({ - models: z.dict(z.object({ - thresholdRatio: z.number(), - retainTokens: z.number().step(1), - })), + thresholdRatio: z.number().default(0.8), + retainTokens: z.number().step(1), summarizationModel: z.string().default(''), maxTokens: z.number().step(1).min(1).default(8192), compactionRetries: z.number().step(1).min(0).default(1), auto: z.boolean().default(true), }) - /** Resolved and validated common configuration plus named partial overrides. */ + /** Resolved and validated compaction configuration. */ readonly config: ResolvedConfig - private readonly modelConfigs = new Map() - constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) this.config = resolveConfig(config, ctx.tokenMeter) @@ -107,9 +99,8 @@ export class BasicCompactService extends CompactService { /** * Check replayed pressure for the provisional pre-step envelope and compact - * a tool-balanced head until it falls below the effective model threshold. - * A genuinely model-less router-first step skips this provisional check; - * naming an unconfigured model throws the token meter's typed error. + * a tool-balanced head until it falls below the service-wide threshold. + * A genuinely model-less router-first step skips this provisional check. * @param agent - agent whose session and provisional model are measured. * @param fullSystemPrompt - current assembled system prompt override. * @param sessionPrefix - current request-only prefix override. @@ -124,10 +115,9 @@ export class BasicCompactService extends CompactService { ): Promise { const model = effectiveModel(agent) if (model === undefined || model.length === 0) return null - const meter = this.ctx.tokenMeter.resolve(model) - const policy = this._modelConfig(meter) + const meter = this.ctx.tokenMeter const requestHeader = provisionalHeader(model, agent.session, fullSystemPrompt, sessionPrefix) - const threshold = Math.floor(policy.contextWindow * policy.thresholdRatio) + const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) let measurement = meter.measure(agent.session, requestHeader) if (measurement.totalTokens < threshold) return null @@ -139,7 +129,7 @@ export class BasicCompactService extends CompactService { `compaction: pressure revision ${measurement.logRevision} does not match surface revision ${surface.logRevision}`, ) } - const range = selectCompactableRange(agent.session, surface, policy.retainTokens) + const range = selectCompactableRange(agent.session, surface, this.config.retainTokens) if (range === null) { /* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */ if (result === null) return null @@ -159,12 +149,12 @@ export class BasicCompactService extends CompactService { /** * Compact one inclusive positional surface range using the effective - * conversation model for all retention and shrink pricing. Reject an agent - * that does not own the exact target before any resolution or mutation. + * token meter for all retention and shrink pricing. Reject an agent that does + * not own the exact target before any mutation. * @param session - session whose surface is mutated; must equal `agent.session`. * @param start - inclusive first surface-node seq. * @param end - inclusive last surface-node seq. - * @param agent - owner of the target session, used by the summarizer and model resolver. + * @param agent - owner of the target session, used by the summarizer. * @param signal - optional summarization cancellation signal. * @returns the successful durable compaction result. */ @@ -178,27 +168,11 @@ export class BasicCompactService extends CompactService { if (session !== agent.session) { throw new Error('compactRegion: agent.session must be the exact target session') } - const model = effectiveModel(agent) - if (model === undefined || model.length === 0) { - throw new Error('compactRegion: no routed or configured conversation model is available for token pricing') - } - const meter = this.ctx.tokenMeter.resolve(model) - this._modelConfig(meter) return compactSurfaceRegion({ - meter, + meter: this.ctx.tokenMeter, summarize: (text, owner, abort) => this.summarize(text, owner, abort), }, session, start, end, agent, signal) } - - /** Resolve and memoize one lazy default/override model policy. */ - private _modelConfig(meter: ModelTokenMeter): ResolvedModelCompactConfig { - let modelConfig = this.modelConfigs.get(meter.model) - if (modelConfig === undefined) { - modelConfig = resolveModelConfig(this.config, meter) - this.modelConfigs.set(meter.model, modelConfig) - } - return modelConfig - } } export default BasicCompactService diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index 1bc9b7b0a7..83ed04a3eb 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -10,14 +10,14 @@ import { toolPairingBalancedBefore, } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import type { ModelTokenMeter, TokenSurfaceMeasurement } from '@deepseek-ai/dsh-token-meter' +import type { TokenMeterService, TokenSurfaceMeasurement } from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import { frameSummary } from './summarizer.ts' import type { SummaryResult } from './summarizer.ts' interface RegionDependencies { - readonly meter: ModelTokenMeter + readonly meter: TokenMeterService summarize(text: string, agent: Agent, signal?: AbortSignal): Promise } diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 44ff06435d..b9f6c4742c 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -4,18 +4,12 @@ * @module @deepseek-ai/dsh-compact-basic/types */ -/** Optional pressure and retention policy for one metered model. */ -export interface ModelCompactConfig { - /** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */ +/** Basic compaction configuration; every common field has a deployment default. */ +export interface BasicCompactConfig { + /** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */ thresholdRatio?: number /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ retainTokens?: number -} - -/** Basic compaction configuration; every common field has a deployment default. */ -export interface BasicCompactConfig { - /** Field-wise pressure/retention overrides keyed by configured token-meter model name. */ - models?: Record /** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */ summarizationModel?: string /** Provider generation cap for summarization. Defaults to `8192`. */ @@ -26,19 +20,12 @@ export interface BasicCompactConfig { auto?: boolean } -/** Validated top-level defaults plus detached per-model partial overrides. */ +/** Validated and detached compaction configuration. */ export interface ResolvedConfig { - readonly models: Readonly>> + readonly thresholdRatio: number + readonly retainTokens: number readonly summarizationModel: string readonly maxTokens: number readonly compactionRetries: number readonly auto: boolean } - -/** Fully resolved pressure/retention policy for one effective model. */ -export interface ResolvedModelCompactConfig { - readonly model: string - readonly contextWindow: number - readonly thresholdRatio: number - readonly retainTokens: number -} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 98b4b2f15b..86471d2630 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,31 +1,21 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import BasicCompactService, { - resolveConfig, - resolveModelConfig, -} from '@deepseek-ai/dsh-compact-basic' +import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import TokenMeterService, { - TOKEN_METER_MODEL_UNCONFIGURED, - TokenMeterError, -} from '@deepseek-ai/dsh-token-meter' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal const MODEL = 'test-model' -function createContext( - models: Record = { - [MODEL]: { contextWindow: 100, charsPerToken: 1_000 }, - }, -): Context { +function createContext(contextWindow = 1_000): Context { const ctx = new Context() - void new TokenMeterService(ctx, { models }) + void new TokenMeterService(ctx, { contextWindow }) return ctx } @@ -34,7 +24,7 @@ function agent(session: Session, model?: string): Agent { } /** Closed two-message turns followed by one open turn for durable compaction events. */ -function conversation(turns = 4, text = 'fixture'): Session { +function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { const session = new Session(SessionId(`conversation-${turns}`)) for (let turn = 1; turn <= turns; turn += 1) { session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -128,83 +118,64 @@ async function compactIfNeeded( } describe('compact configuration and defaults', () => { - it('uses low-friction common and per-profile defaults', () => { - const ctx = createContext({ - [MODEL]: { contextWindow: 100, charsPerToken: 1_000 }, - large: { contextWindow: 1_000, charsPerToken: 4 }, - }) + it('uses low-friction service-wide defaults', () => { + const ctx = createContext() const resolved = resolveConfig({}, ctx.tokenMeter) expect(resolved).toEqual({ - models: {}, + thresholdRatio: 0.8, + retainTokens: 160, summarizationModel: '', maxTokens: 8192, compactionRetries: 1, auto: true, }) - expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve(MODEL))).toEqual({ - model: MODEL, - contextWindow: 100, - thresholdRatio: 0.8, - retainTokens: 16, - }) - expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve('large')).retainTokens).toBe(160) expect(Object.isFrozen(resolved)).toBe(true) }) - it('merges threshold and retention overrides field-wise', () => { + it('resolves threshold and retention overrides independently', () => { const ctx = createContext() const thresholdOnly = resolveConfig({ - models: { [MODEL]: { thresholdRatio: 0.5 } }, - }, ctx.tokenMeter) - expect(resolveModelConfig(thresholdOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({ thresholdRatio: 0.5, - retainTokens: 16, + }, ctx.tokenMeter) + expect(thresholdOnly).toMatchObject({ + thresholdRatio: 0.5, + retainTokens: 160, }) const retentionOnly = resolveConfig({ - models: { [MODEL]: { retainTokens: 7 } }, + retainTokens: 70, }, ctx.tokenMeter) - expect(resolveModelConfig(retentionOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({ + expect(retentionOnly).toMatchObject({ thresholdRatio: 0.8, - retainTokens: 7, + retainTokens: 70, }) }) - it('validates common values and model policy invariants', () => { + it('validates common values and pressure-policy invariants', () => { const ctx = createContext() const bad = [ [{ maxTokens: 0 }, /maxTokens/], [{ compactionRetries: -1 }, /compactionRetries/], [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], - [{ models: null }, /models must be an object/], - [{ models: { [MODEL]: null } }, /must be an object/], - [{ models: { [MODEL]: { thresholdRatio: 0 } } }, /number in \(0, 1\]/], - [{ models: { [MODEL]: { thresholdRatio: 1.1 } } }, /number in \(0, 1\]/], - [{ models: { [MODEL]: { retainTokens: -1 } } }, /non-negative integer/], - [{ models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 50 } } }, /less than threshold/], + [{ thresholdRatio: 0 }, /number in \(0, 1\]/], + [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/], + [{ retainTokens: -1 }, /non-negative integer/], + [{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/], ] as Array<[unknown, RegExp]> for (const [config, pattern] of bad) { expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern) } }) - - it('rejects an override for an unknown meter profile with the exact typed error', () => { - const ctx = createContext() - expect(() => resolveConfig({ models: { missing: { retainTokens: 1 } } }, ctx.tokenMeter)) - .toThrow(expect.objectContaining({ - code: TOKEN_METER_MODEL_UNCONFIGURED, - model: 'missing', - })) - }) }) describe('pressure measurement and retention', () => { const compactConfig: BasicCompactConfig = { auto: false, - models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + thresholdRatio: 0.5, + retainTokens: 180, } it('skips the provisional check only when no routed or fallback model exists', async () => { @@ -214,10 +185,10 @@ describe('pressure measurement and retention', () => { expect(compact.calls).toHaveLength(0) }) - it('throws for a named unconfigured model instead of swallowing it', async () => { + it('meters any routed model without profile resolution', async () => { const compact = service(compactConfig) - await expect(compactIfNeeded(compact, conversation(), 'missing')) - .rejects.toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED, model: 'missing' }) + await expect(compactIfNeeded(compact, conversation(), 'unlisted-model')) + .resolves.not.toBeNull() }) it('does nothing below threshold and compacts a priced head above threshold', async () => { @@ -234,38 +205,39 @@ describe('pressure measurement and retention', () => { it('counts the current prompt and request prefix without putting either on the surface', async () => { const compact = service({ auto: false, - models: { [MODEL]: { thresholdRatio: 0.7, retainTokens: 9 } }, + thresholdRatio: 0.7, + retainTokens: 50, }) - const session = conversation(2, 'x'.repeat(2_000)) + const session = conversation(2, 'x'.repeat(200)) expect(await compactIfNeeded(compact, session)).toBeNull() const prefix: Message[] = [{ role: 'user', - content: [{ type: 'text', text: 'p'.repeat(10_000) }], + content: [{ type: 'text', text: 'p'.repeat(1_000) }], }] - const result = await compactIfNeeded(compact, session, MODEL, 's'.repeat(5_000), prefix) + const result = await compactIfNeeded(compact, session, MODEL, 's'.repeat(1_000), prefix) expect(result).not.toBeNull() expect(prefix).toHaveLength(1) expect(session.events.some(event => event.type === 'context/message')).toBe(false) }) - it('uses the latest logged routed model instead of AgentOptions.model', async () => { - const ctx = createContext({ - actual: { contextWindow: 100, charsPerToken: 1_000 }, - fallback: { contextWindow: 10_000, charsPerToken: 1_000 }, - }) + it('uses the latest logged routed model in the provisional request envelope', async () => { + const ctx = createContext() const compact = service({ auto: false, - models: { actual: { thresholdRatio: 0.5, retainTokens: 18 } }, + thresholdRatio: 0.5, + retainTokens: 180, }, ctx) const session = conversation(4) session.append('request/header', { header: { config: { model: 'actual' } }, reason: 'initial', }) + const measure = vi.spyOn(ctx.tokenMeter, 'measure') const result = await compactIfNeeded(compact, session, 'fallback') expect(result).not.toBeNull() + expect(measure.mock.calls[0]?.[1]?.config.model).toBe('actual') }) it('declines when envelope pressure is high but the surface has no compactable range', async () => { @@ -279,7 +251,7 @@ describe('pressure measurement and retention', () => { it('detects scalar/surface revision disagreement', async () => { const ctx = createContext() - const meter = ctx.tokenMeter.resolve(MODEL) + const meter = ctx.tokenMeter const original = meter.measureSurface.bind(meter) vi.spyOn(meter, 'measureSurface').mockImplementation((session) => { const measurement = original(session) @@ -294,7 +266,8 @@ describe('pressure measurement and retention', () => { const compact = service({ auto: false, compactionRetries: 0, - models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + thresholdRatio: 0.3, + retainTokens: 180, }) compact.summary = Array.from({ length: 7 }, (_, index) => ({ type: 'text', @@ -308,8 +281,9 @@ describe('pressure measurement and retention', () => { it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => { const compact = service({ auto: false, - models: { [MODEL]: { thresholdRatio: 0.8, retainTokens: 8 } }, - }) + thresholdRatio: 0.8, + retainTokens: 80, + }, createContext(4_000)) const session = toolConversation() const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() @@ -327,7 +301,7 @@ describe('pressure measurement and retention', () => { it('rejects a priced surface that is not the current positional surface', () => { const ctx = createContext() const session = conversation(2) - const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session) + const priced = ctx.tokenMeter.measureSurface(session) expect(() => selectCompactableRange(session, { ...priced, nodes: priced.nodes.slice(1), @@ -355,7 +329,7 @@ describe('pressure measurement and retention', () => { }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) - const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session) + const priced = ctx.tokenMeter.measureSurface(session) expect(selectCompactableRange(session, priced, 1)).toBeNull() }) }) @@ -497,7 +471,7 @@ describe('compaction region transaction', () => { it('rejects a meter snapshot that changed before summarization began', async () => { const ctx = createContext() - const meter = ctx.tokenMeter.resolve(MODEL) + const meter = ctx.tokenMeter const original = meter.measureSurface.bind(meter) vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => { const measurement = original(session) @@ -569,7 +543,7 @@ describe('compaction region transaction', () => { it('rejects a non-shrinking framed summary under the conversation meter', async () => { const compact = service() - compact.summary = Array.from({ length: 20 }, (_, index) => ({ + compact.summary = Array.from({ length: 100 }, (_, index) => ({ type: 'text', text: `verbose ${index}`, })) @@ -585,7 +559,7 @@ describe('compaction region transaction', () => { expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) - it('requires a conversation model for pricing', async () => { + it('lets a model-independent custom summarizer compact without a conversation model', async () => { const compact = service() const session = conversation(1) const nodes = session.surface.nodes @@ -594,7 +568,7 @@ describe('compaction region transaction', () => { nodes[0]!.seq, nodes[1]!.seq, agent(session), - )).rejects.toThrow(/no routed or configured conversation model/) + )).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!.seq, nodes[1]!.seq] }) }) }) @@ -632,7 +606,7 @@ async function summarizerHarness( ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> { const ctx = new Context() await ctx.plugin(LlmService) - void new TokenMeterService(ctx, { models: { [model]: { contextWindow: 100 } } }) + void new TokenMeterService(ctx, { contextWindow: 1_000 }) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) const compact = new BasicCompactService(ctx, config) @@ -724,7 +698,8 @@ describe('automatic listener and loader composition', () => { it('compacts above threshold and remains idle below it', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { - models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + thresholdRatio: 0.5, + retainTokens: 180, }) const pressured = conversation(4) await preStep(ctx, agent(pressured, MODEL)) @@ -741,7 +716,8 @@ describe('automatic listener and loader composition', () => { const warnings: string[] = [] ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn const compact = new TestCompactService(ctx, { - models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + thresholdRatio: 0.5, + retainTokens: 180, }) compact.error = 'temporary failure' const session = conversation(4) @@ -751,20 +727,12 @@ describe('automatic listener and loader composition', () => { expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) - it('propagates a named unknown-model configuration failure', async () => { - const ctx = createContext() - void new TestCompactService(ctx) - await expect(preStep(ctx, agent(conversation(4), 'missing'))).rejects.toMatchObject({ - code: TOKEN_METER_MODEL_UNCONFIGURED, - model: 'missing', - }) - }) - it('auto:false installs no listener', async () => { const ctx = createContext() void new TestCompactService(ctx, { auto: false, - models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + thresholdRatio: 0.5, + retainTokens: 180, }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) @@ -777,7 +745,7 @@ describe('automatic listener and loader composition', () => { const meterFiber = await ctx.plugin(TokenMeterService) const compactFiber = await ctx.plugin(BasicCompactService, { auto: false }) - expect(ctx.tokenMeter.resolve('deepseek-v4-flash').contextWindow).toBe(128_000) + expect(ctx.tokenMeter.contextWindow).toBe(128_000) expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) await compactFiber.dispose() expect(ctx.get('compact')).toBeUndefined() @@ -788,11 +756,10 @@ describe('automatic listener and loader composition', () => { it('removes its automatic listener with the plugin fiber', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(TokenMeterService, { - models: { [MODEL]: { contextWindow: 100, charsPerToken: 1_000 } }, - }) + await ctx.plugin(TokenMeterService, { contextWindow: 1_000 }) const fiber = await ctx.plugin(TestCompactService, { - models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } }, + thresholdRatio: 0.5, + retainTokens: 180, }) await fiber.dispose() @@ -801,17 +768,3 @@ describe('automatic listener and loader composition', () => { expect(session.events.some(event => event.type === 'compact/start')).toBe(false) }) }) - -describe('typed unknown-model boundary', () => { - it('uses TokenMeterError identity rather than message matching', () => { - const ctx = createContext() - let thrown: unknown - try { - ctx.tokenMeter.resolve('missing') - } catch (error: unknown) { - thrown = error - } - expect(thrown).toBeInstanceOf(TokenMeterError) - expect(thrown).toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED }) - }) -}) 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 cc1111c5f1..e02e788074 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -62,9 +62,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TokenMeterService, { - models: { mock: { contextWindow: 64, charsPerToken: 1_000 } }, - }) + await ctx.plugin(TokenMeterService, { contextWindow: 400 }) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) ctx.tools.register(defineTool({ name: 'work', @@ -74,11 +72,12 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr return [{ type: 'text', text: 'work result' }] }, })) - // Tiny window so a couple of tool steps cross the threshold and compaction - // fires within the runaway turn. + // Small window so several tool steps cross the threshold and compaction + // fires within the runaway turn after enough history can shrink. const compact = new ReproCompactService(ctx, { auto: true, - models: { mock: { thresholdRatio: 0.5, retainTokens: 20 } }, + thresholdRatio: 0.5, + retainTokens: 50, summarizationModel: '', maxTokens: 8192, compactionRetries: 1, diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index ff6320c05f..26ff37a8e6 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -57,10 +57,7 @@ describe('real Loader composition', () => { .filter(entry => entry.fiber === undefined && !entry.disabled) .map(entry => entry.options.name) expect(unloaded).toEqual([]) - expect(context.tokenMeter.resolve('deepseek-v4-flash')).toMatchObject({ - contextWindow: 128_000, - charsPerToken: 4, - }) + expect(context.tokenMeter.contextWindow).toBe(128_000) expect(context.get('compact')).toBeInstanceOf(BasicCompactService) }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 25dafac885..6edd2b809e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -224,9 +224,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'tokenMeter', - summary: 'Concrete registry and replay owner for all configured model meters.', + summary: 'Replay owner for one service-wide estimator and isolated per-session folds.', methods: [ - 'resolve(model: string): ModelTokenMeter', + 'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement', + 'measureSurface(session: Session): TokenSurfaceMeasurement', + 'estimateMessage(message: Message): number', ], }, { @@ -760,10 +762,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}', }, - { - name: 'ModelTokenMeter', - declaration: 'export interface ModelTokenMeter {\n readonly model: string;\n readonly contextWindow: number;\n readonly charsPerToken: number;\n measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement;\n measureSurface(session: Session): TokenSurfaceMeasurement;\n estimateMessage(message: Message): number;\n}', - }, { name: 'PresetOption', declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', @@ -994,7 +992,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TokenMeasurement', - declaration: 'export interface TokenMeasurement {\n readonly model: string;\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n}', + declaration: 'export interface TokenMeasurement {\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n}', }, { name: 'TokenMeasurementBaseline', @@ -1002,7 +1000,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TokenSurfaceMeasurement', - declaration: 'export interface TokenSurfaceMeasurement {\n readonly model: string;\n readonly logRevision: number;\n readonly totalTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}', + declaration: 'export interface TokenSurfaceMeasurement {\n readonly logRevision: number;\n readonly totalTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}', }, { name: 'TokenSurfaceNode', diff --git a/packages/llm/README.md b/packages/llm/README.md index f4d9dd82cb..61368d81f0 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -5,7 +5,7 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a | Package | Role | ctx key | |---|---|---| | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | -| `token-meter/` | Replay-aware, per-model request and surface token measurement | `ctx.tokenMeter` | +| `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index e50ee84b3b..85f14aed35 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -1,29 +1,26 @@ # @deepseek-ai/dsh-token-meter -Replay-aware token measurement through `ctx.tokenMeter`. The service binds one stable meter to each configured model and advances isolated per-model/per-session folds from the durable session log. Compaction consumes it today; other pressure-sensitive plugins can reuse the same accounting without depending on `CompactService`. +Replay-aware token measurement through the singleton `ctx.tokenMeter` service. It advances one isolated fold per session from the durable log, so compaction and other pressure-sensitive plugins can share accounting without depending on `CompactService`. -## Profiles and configuration - -The built-in `deepseek-v4-flash` and `deepseek-v4-pro` profiles each use a 128,000-token context window and four characters per estimated token. `models` merges overrides field-by-field, so changing only density keeps the built-in window. A custom model requires `contextWindow`; its `charsPerToken` defaults to `4`. +## Configuration | Key | Default | Contract | |---|---:|---| -| `models..contextWindow` | `128000` | Positive integer provider capacity. | -| `models..charsPerToken` | `4` | Positive finite heuristic density. | +| `contextWindow` | `128000` | Positive integer service-wide context capacity. | -Resolving an unknown model throws `TokenMeterError` with code `TOKEN_METER_MODEL_UNCONFIGURED` and preserves the exact model name. Direct-construction profile validation uses `TOKEN_METER_INVALID_CONFIG`; Loader mounts first apply the package's Schemastery shape validation. There is no universal fallback window. +The estimator intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. `contextWindow` is the only deployment setting. Direct construction validates it; Loader mounts first apply the package's Schemastery shape validation. ## Measurement contract -`ctx.tokenMeter.resolve(model)` returns a `ModelTokenMeter` with three operations: +`ctx.tokenMeter` directly exposes three operations: - `measure(session, requestHeader?)` returns scalar request pressure at one consumed-log revision. - `measureSurface(session)` returns current surface nodes and their per-node prices at the same kind of revision. -- `estimateMessage(message)` prices one detached message under that profile. +- `estimateMessage(message)` prices one message with the fixed heuristic. Measurements are detached and deeply immutable. A caller that needs a consistent scalar/surface decision compares their `logRevision` values instead of copying the full history on every read. -The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the handle's model and the canonical request envelope match the successful-call anchor. Otherwise the complete current envelope and surface are repriced under the requested model. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements. +The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements. Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output. @@ -34,16 +31,12 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket - name: '@deepseek-ai/dsh-compact-basic' ``` -Both plugins have usable defaults for the bundled DeepSeek profiles. Custom deployments can override only the fields that differ: +Both plugins have usable defaults. A deployment with a different capacity configures the meter once: ```yaml - name: '@deepseek-ai/dsh-token-meter' config: - models: - deepseek-v4-flash: - charsPerToken: 2 - local-model: - contextWindow: 32768 + contextWindow: 32768 ``` ## Model Experience @@ -52,6 +45,6 @@ Indirectly, through consumers such as `dsh-compact-basic`; the service itself ad ## Known Limitations and Deferred Work -- **Heuristic density still needs maintenance** — message content without provider usage is priced by configured character density plus structural overhead, not an exact provider tokenizer. CJK-heavy or provider-specific formats may need profile overrides. -- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, or call-config changes deliberately fall back to full heuristic repricing. +- **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer. +- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, model, or call-config changes deliberately fall back to full heuristic estimation. - **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream. diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 30031b0b2e..13fa4e3cdc 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-token-meter", - "description": "Replay-aware per-model token measurement service (ctx.tokenMeter) for the DeepSeek Harness", + "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 19d700ac96..0b5b3b062d 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -1,59 +1,85 @@ /** - * Replay token-meter service with model-specific context capacity and pricing. + * Single replay-aware token-meter service for request and surface pressure. * * @module @deepseek-ai/dsh-token-meter */ import { Context, Service } from 'cordis' import z from 'schemastery' -import { HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' -import type { Session } from '@deepseek-ai/dsh-session' -import { ReplayModelTokenMeter } from './replay.ts' -import type { ModelTokenProfile } from './replay.ts' +import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' +import { applyHeaderDelta, canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' import type { - ModelTokenMeter, - ModelTokenMeterConfig, + TokenMeasurement, + TokenMeasurementBaseline, TokenMeterConfig, + TokenSurfaceMeasurement, + TokenSurfaceNode, } from './types.ts' export type * from './types.ts' -/** Exact error code for resolving a model without a configured profile. */ -export const TOKEN_METER_MODEL_UNCONFIGURED = 'TOKEN_METER_MODEL_UNCONFIGURED' +/** Default service-wide provider context capacity. */ +const DEFAULT_CONTEXT_WINDOW = 128_000 -/** Exact error code for invalid token-meter configuration. */ -export const TOKEN_METER_INVALID_CONFIG = 'TOKEN_METER_INVALID_CONFIG' +/** Fixed text-density estimate used until exact tokenization is needed. */ +const CHARS_PER_TOKEN = 4 -/** Closed machine-routable token-meter failure taxonomy. */ -export type TokenMeterErrorCode = - | typeof TOKEN_METER_MODEL_UNCONFIGURED - | typeof TOKEN_METER_INVALID_CONFIG +/** Per-block structural overhead for JSON framing and type tags. */ +const BLOCK_OVERHEAD = 4 -/** Built-in DeepSeek model profiles available with zero configuration. */ -const BUILTIN_TOKEN_PROFILES: Readonly>> = deepFreeze({ - 'deepseek-v4-flash': { - model: 'deepseek-v4-flash', - contextWindow: 128_000, - charsPerToken: 4, - }, - 'deepseek-v4-pro': { - model: 'deepseek-v4-pro', - contextWindow: 128_000, - charsPerToken: 4, - }, -}) +/** Role-field framing overhead added to every priced message. */ +const ROLE_OVERHEAD = 4 -/** Typed token-meter failure with the affected model preserved for callers. */ -export class TokenMeterError extends HarnessError { - declare readonly code: TokenMeterErrorCode - /** Exact model name involved in this error, when applicable. */ - readonly model: string | undefined +interface MeasurementAnchor { + readonly header: EpochHeader | undefined + readonly surfaceTokens: number + readonly baseline: Exclude +} - constructor(message: string, code: TokenMeterErrorCode, model?: string, options?: ErrorOptions) { - super(message, code, options) - this.name = 'TokenMeterError' - this.model = model +interface ReplayState { + consumedEvents: number + header: EpochHeader | undefined + surface: TokenSurfaceNode[] + surfaceTokens: number + stepStart: { turn: number; step: number; surfaceTokens: number } | undefined + anchor: MeasurementAnchor | undefined +} + +interface PreparedSurfaceMutation { + readonly tokens: number + commit(state: ReplayState): void +} + +/** Sum disjoint provider usage buckets without double-counting reasoning output. */ +function usageTokens(usage: TokenUsage): number { + return usage.inputTokens + + (usage.cacheReadTokens ?? 0) + + (usage.cacheWriteTokens ?? 0) + + usage.outputTokens +} + +/** Compare optional envelopes so a headerless estimate can track later surface deltas. */ +function optionalHeaderEquals( + left: EpochHeader | undefined, + right: EpochHeader | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right + return headerEquals(left, right) +} + +/** Resolve and validate the one service-wide context capacity. */ +function resolveContextWindow(config: TokenMeterConfig): number { + const contextWindow = config.contextWindow === undefined + ? DEFAULT_CONTEXT_WINDOW + : config.contextWindow + if (!Number.isInteger(contextWindow) || contextWindow <= 0) { + throw new Error( + `TokenMeterConfig: contextWindow (${contextWindow}) must be a positive integer`, + ) } + return contextWindow } declare module 'cordis' { @@ -62,131 +88,327 @@ declare module 'cordis' { } } -/** Validate and detach all configured model profiles. */ -function resolveProfiles(config: TokenMeterConfig): readonly ModelTokenProfile[] { - const profiles = new Map() - for (const profile of Object.values(BUILTIN_TOKEN_PROFILES)) { - profiles.set(profile.model, { ...profile }) - } - - const configuredValue: unknown = config.models - const configuredModels = configuredValue === undefined ? {} : configuredValue - if (typeof configuredModels !== 'object' - || configuredModels === null - || Array.isArray(configuredModels)) { - throw new TokenMeterError( - 'TokenMeterConfig: models must be an object', - TOKEN_METER_INVALID_CONFIG, - ) - } - - for (const [model, override] of Object.entries(configuredModels as Record)) { - if (model.length === 0) { - throw new TokenMeterError( - 'TokenMeterConfig: model names must not be empty', - TOKEN_METER_INVALID_CONFIG, - model, - ) - } - assertProfileObject(model, override) - const builtIn = profiles.get(model) - const contextWindow = override.contextWindow ?? builtIn?.contextWindow - const charsPerToken = override.charsPerToken ?? builtIn?.charsPerToken ?? 4 - if (contextWindow === undefined) { - throw new TokenMeterError( - `TokenMeterConfig: custom model "${model}" requires contextWindow`, - TOKEN_METER_INVALID_CONFIG, - model, - ) - } - assertPositiveInteger(model, 'contextWindow', contextWindow) - assertPositiveFinite(model, 'charsPerToken', charsPerToken) - profiles.set(model, { model, contextWindow, charsPerToken }) - } - - for (const profile of profiles.values()) { - assertPositiveInteger(profile.model, 'contextWindow', profile.contextWindow) - assertPositiveFinite(profile.model, 'charsPerToken', profile.charsPerToken) - } - return deepFreeze([...profiles.values()].map(profile => ({ ...profile }))) -} - -function assertProfileObject(model: string, value: unknown): asserts value is ModelTokenMeterConfig { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new TokenMeterError( - `TokenMeterConfig: profile "${model}" must be an object`, - TOKEN_METER_INVALID_CONFIG, - model, - ) - } -} - -function assertPositiveInteger(model: string, name: string, value: number): void { - if (!Number.isInteger(value) || value <= 0) { - throw new TokenMeterError( - `TokenMeterConfig: ${model}.${name} (${value}) must be a positive integer`, - TOKEN_METER_INVALID_CONFIG, - model, - ) - } -} - -function assertPositiveFinite(model: string, name: string, value: number): void { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - throw new TokenMeterError( - `TokenMeterConfig: ${model}.${name} (${value}) must be a positive finite number`, - TOKEN_METER_INVALID_CONFIG, - model, - ) - } -} - -/** Concrete registry and replay owner for all configured model meters. */ +/** Replay owner for one service-wide estimator and isolated per-session folds. */ export class TokenMeterService extends Service { static Config: z = z.object({ - models: z.dict(z.object({ - contextWindow: z.number(), - charsPerToken: z.number(), - })), + contextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), }) - private readonly meters = new Map() + /** Provider context-window capacity used by pressure consumers. */ + readonly contextWindow: number + + private readonly states = new WeakMap() constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') - for (const profile of resolveProfiles(config)) { - this.meters.set(profile.model, new ReplayModelTokenMeter(profile)) - } + this.contextWindow = resolveContextWindow(config) // Readers catch up independently, while eager observation bounds ordinary - // read latency. A reader in an earlier listener consumes the new event; - // this listener then sees the same revision and performs no duplicate fold. + // read latency without creating state for sessions no consumer has read. ctx.on('session/event', (session) => { - this._observe(session) + if (this.states.has(session)) this._sync(session) }) } /** - * Resolve one stable model-bound replay handle. - * @param model - exact routed model name. - * @throws {@link TokenMeterError} with `TOKEN_METER_MODEL_UNCONFIGURED` when no profile exists. - * @returns the configured handle for this model. + * Measure current request pressure through the session's durable tail. + * + * Provider usage is reused only when the latest successful call's canonical + * request envelope matches `requestHeader`; otherwise the complete envelope + * and surface are heuristically repriced. + * + * @param session - session to replay through its current durable tail. + * @param requestHeader - optional effective request envelope replacing the latest logged header. + * @returns a detached deeply immutable pressure measurement. */ - resolve(model: string): ModelTokenMeter { - const meter = this.meters.get(model) - if (meter === undefined) { - throw new TokenMeterError( - `token meter has no profile for model "${model}"`, - TOKEN_METER_MODEL_UNCONFIGURED, - model, - ) + measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement { + const state = this._sync(session) + const header = requestHeader === undefined + ? state.header + : canonicalHeader(requestHeader) + const anchor = state.anchor + + let baseline: TokenMeasurementBaseline + let surfaceDeltaTokens: number + if (anchor !== undefined && optionalHeaderEquals(anchor.header, header)) { + baseline = anchor.baseline + surfaceDeltaTokens = state.surfaceTokens - anchor.surfaceTokens + } else if (header === undefined && state.surfaceTokens === 0) { + baseline = { kind: 'none', tokens: 0 } + surfaceDeltaTokens = 0 + } else { + baseline = { + kind: 'estimated', + tokens: this._estimateHeader(header) + state.surfaceTokens, + } + surfaceDeltaTokens = 0 } - return meter + + return deepFreeze(structuredClone({ + logRevision: state.consumedEvents, + baseline, + surfaceDeltaTokens, + totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens), + })) } - /** Advance every configured model's isolated replay fold. */ - private _observe(session: Session): void { - for (const meter of this.meters.values()) meter.observeIfActive(session) + /** + * Price the current surface for retention and replacement decisions. + * @param session - session to replay through its current durable tail. + * @returns a detached deeply immutable positional surface measurement. + */ + measureSurface(session: Session): TokenSurfaceMeasurement { + const state = this._sync(session) + return deepFreeze(structuredClone({ + logRevision: state.consumedEvents, + totalTokens: state.surfaceTokens, + nodes: state.surface, + })) + } + + /** + * Heuristically price one model-visible message. + * @param message - message to price without mutation. + * @returns content and role-framing tokens under the fixed service heuristic. + */ + estimateMessage(message: Message): number { + return this._estimateContent(message.content) + ROLE_OVERHEAD + } + + /** Catch one session's fold up to the current durable tail. */ + private _sync(session: Session): ReplayState { + let state = this.states.get(session) + if (state === undefined) { + state = { + consumedEvents: 0, + header: undefined, + surface: [], + surfaceTokens: 0, + stepStart: undefined, + anchor: undefined, + } + this.states.set(session, state) + } + + while (state.consumedEvents < session.events.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- contiguous session seqs index the durable log + const event = session.events[state.consumedEvents]! + this._foldEvent(session, state, event) + state.consumedEvents += 1 + } + return state + } + + /** + * Validate and prepare every fallible part before mutating replay state. + * A malformed event remains unread on every retry instead of partially + * applying the same mutation more than once. + */ + private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void { + let nextHeader = state.header + let nextStepStart = state.stepStart + let nextAnchor = state.anchor + + switch (event.type) { + case 'request/header': + nextHeader = canonicalHeader(event.data.header) + break + case 'request/header-delta': + if (state.header === undefined) { + throw new Error(`token meter: request/header-delta at seq ${event.seq} has no preceding header`) + } + nextHeader = applyHeaderDelta(state.header, event.data) + break + case 'step/start': + if (state.stepStart !== undefined) { + throw new Error( + `token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`, + ) + } + nextStepStart = { ...event.data, surfaceTokens: state.surfaceTokens } + break + case 'step/end': + if (state.stepStart === undefined + || state.stepStart.turn !== event.data.turn + || state.stepStart.step !== event.data.step) { + throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`) + } + nextStepStart = undefined + break + default: + break + } + + const surface = isSurfaceEvent(event) + ? this._prepareSurfaceMutation(session, state, event) + : undefined + + if (event.type === 'assistant/message') { + const stepStart = state.stepStart + if (stepStart === undefined + || stepStart.turn !== event.data.turn + || stepStart.step !== event.data.step) { + throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`) + } + + // assistant/message is surface-mandatory at every append/seed boundary. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const eventTokens = surface!.tokens + if (event.data.usage !== undefined && nextHeader !== undefined) { + const providerAssistantTokens = this._estimateProviderAssistant( + session, + event, + eventTokens, + ) + nextAnchor = { + header: nextHeader, + surfaceTokens: stepStart.surfaceTokens + providerAssistantTokens, + baseline: { + kind: 'usage', + tokens: usageTokens(event.data.usage), + usage: event.data.usage, + }, + } + } else { + const anchorSurfaceTokens = stepStart.surfaceTokens + eventTokens + nextAnchor = { + header: nextHeader, + surfaceTokens: anchorSurfaceTokens, + baseline: { + kind: 'estimated', + tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens, + }, + } + } + } + + state.header = nextHeader + state.stepStart = nextStepStart + if (surface !== undefined) surface.commit(state) + state.anchor = nextAnchor + } + + /** Validate one surface operation and return its allocation-light commit. */ + private _prepareSurfaceMutation( + session: Session, + state: ReplayState, + event: SurfaceEvent, + ): PreparedSurfaceMutation { + const tokens = this._estimateSurfaceEvent(session, event) + const op = event.surfaceOp + if (op === 'append') { + return { + tokens, + commit(target) { + target.surface.push({ seq: event.seq, tokens }) + target.surfaceTokens += tokens + }, + } + } + + const startIdx = state.surface.findIndex(node => node.seq === op.start) + const endIdx = state.surface.findIndex(node => node.seq === op.end) + if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { + throw new Error( + `token meter: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, + ) + } + const removedTokens = state.surface + .slice(startIdx, endIdx + 1) + .reduce((total, node) => total + node.tokens, 0) + return { + tokens, + commit(target) { + target.surface.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) + target.surfaceTokens += tokens - removedTokens + }, + } + } + + /** Price one current surface event exactly as it projects to a request. */ + private _estimateSurfaceEvent(session: Session, event: SurfaceEvent): number { + const message = session.deriveEventMessage(event) + return message === null ? 0 : this.estimateMessage(message) + } + + /** + * Reassemble provider output from exact chunk provenance for a usage anchor. + * Missing legacy provenance conservatively treats the durable output as the + * provider output; explicit empty provenance prices a known empty stream. + */ + private _estimateProviderAssistant( + session: Session, + event: SessionEvent<'assistant/message'>, + durableEventTokens: number, + ): number { + const sourceSeqs = event.sourceEventSeqs + if (sourceSeqs === undefined) return durableEventTokens + + const assembler = new BlockAssembler() + const seen = new Set() + for (const seq of sourceSeqs) { + if (seq >= event.seq) { + throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not earlier`) + } + if (seen.has(seq)) { + throw new Error(`token meter: assistant/message at seq ${event.seq} repeats source seq ${seq}`) + } + seen.add(seq) + // Session construction validates contiguous seqs, and the explicit + // earlier-than-assistant check above therefore guarantees existence. + const source = session.events[seq] + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sourceEvent = source! + if (sourceEvent.type !== 'assistant/chunk') { + throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`) + } + if (sourceEvent.data.turn !== event.data.turn || sourceEvent.data.step !== event.data.step) { + throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} belongs to another step`) + } + assembler.push(sourceEvent.data.chunk) + } + const providerMessage = assembler.message() + return providerMessage.content.length === 0 ? 0 : this.estimateMessage(providerMessage) + } + + /** Price content blocks recursively under the fixed density heuristic. */ + private _estimateContent(blocks: readonly ContentBlock[]): number { + let tokens = 0 + for (const block of blocks) { + switch (block.type) { + case 'text': + case 'reasoning': + tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD + break + case 'tool-call': + tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) + + BLOCK_OVERHEAD + break + case 'tool-result': + tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD + break + default: + // ContentBlockMap is merge-extensible; unknown blocks retain a + // conservative structural JSON price under the fixed heuristic. + tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN) + } + } + return tokens + } + + /** Price the canonical non-surface request envelope. */ + private _estimateHeader(header: EpochHeader | undefined): number { + if (header === undefined) return 0 + let tokens = 0 + for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message) + if (header.system !== undefined) { + tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD + } + if (header.tools !== undefined && header.tools.length > 0) { + tokens += Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD + } + return tokens } } diff --git a/packages/llm/token-meter/src/replay.ts b/packages/llm/token-meter/src/replay.ts deleted file mode 100644 index 8b4af52dc5..0000000000 --- a/packages/llm/token-meter/src/replay.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * Model-bound transactional replay of request headers, surface mutations, and - * successful-call token anchors. - * - * @module @deepseek-ai/dsh-token-meter/replay - */ - -import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' -import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' -import { applyHeaderDelta, canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' -import type { - ModelTokenMeter, - TokenMeasurement, - TokenMeasurementBaseline, - TokenSurfaceMeasurement, - TokenSurfaceNode, -} from './types.ts' - -/** Internal validated pricing profile. */ -export interface ModelTokenProfile { - readonly model: string - readonly contextWindow: number - readonly charsPerToken: number -} - -/** Per-block structural overhead for JSON framing and type tags. */ -const BLOCK_OVERHEAD = 4 - -/** Role-field framing overhead added to every priced message. */ -const ROLE_OVERHEAD = 4 - -interface UsageAnchor { - readonly header: EpochHeader - readonly surfaceTokens: number - readonly baseline: Exclude -} - -interface ReplayState { - consumedEvents: number - header: EpochHeader | undefined - surface: TokenSurfaceNode[] - surfaceTokens: number - stepStart: { turn: number; step: number; surfaceTokens: number } | undefined - anchor: UsageAnchor | undefined -} - -interface PreparedSurfaceMutation { - readonly tokens: number - commit(state: ReplayState): void -} - -/** Sum disjoint provider usage buckets without double-counting reasoning output. */ -function usageTokens(usage: TokenUsage): number { - return usage.inputTokens - + (usage.cacheReadTokens ?? 0) - + (usage.cacheWriteTokens ?? 0) - + usage.outputTokens -} - -/** One configured model's replay fold, weakly isolated by session identity. */ -export class ReplayModelTokenMeter implements ModelTokenMeter { - readonly model: string - readonly contextWindow: number - readonly charsPerToken: number - - private readonly states = new WeakMap() - - constructor(profile: ModelTokenProfile) { - this.model = profile.model - this.contextWindow = profile.contextWindow - this.charsPerToken = profile.charsPerToken - } - - /** - * Advance an already-read model/session fold without creating unused state. - * @param session - session whose durable tail advanced. - */ - observeIfActive(session: Session): void { - if (this.states.has(session)) this._sync(session) - } - - /** @inheritdoc */ - estimateMessage(message: Message): number { - return this._estimateContent(message.content) + ROLE_OVERHEAD - } - - /** @inheritdoc */ - measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement { - const state = this._sync(session) - const header = requestHeader === undefined - ? state.header - : canonicalHeader(requestHeader) - const anchor = state.anchor - - let baseline: TokenMeasurementBaseline - let surfaceDeltaTokens: number - if (anchor !== undefined && header !== undefined && headerEquals(anchor.header, header)) { - baseline = anchor.baseline - surfaceDeltaTokens = state.surfaceTokens - anchor.surfaceTokens - } else if (header === undefined && state.surfaceTokens === 0) { - baseline = { kind: 'none', tokens: 0 } - surfaceDeltaTokens = 0 - } else { - baseline = { - kind: 'estimated', - tokens: this._estimateHeader(header) + state.surfaceTokens, - } - surfaceDeltaTokens = 0 - } - - return deepFreeze(structuredClone({ - model: this.model, - logRevision: state.consumedEvents, - baseline, - surfaceDeltaTokens, - totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens), - })) - } - - /** @inheritdoc */ - measureSurface(session: Session): TokenSurfaceMeasurement { - const state = this._sync(session) - return deepFreeze(structuredClone({ - model: this.model, - logRevision: state.consumedEvents, - totalTokens: state.surfaceTokens, - nodes: state.surface, - })) - } - - /** Catch one session's fold up to the current durable tail. */ - private _sync(session: Session): ReplayState { - let state = this.states.get(session) - if (state === undefined) { - state = { - consumedEvents: 0, - header: undefined, - surface: [], - surfaceTokens: 0, - stepStart: undefined, - anchor: undefined, - } - this.states.set(session, state) - } - - while (state.consumedEvents < session.events.length) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- contiguous session seqs index the durable log - const event = session.events[state.consumedEvents]! - this._foldEvent(session, state, event) - state.consumedEvents += 1 - } - return state - } - - /** - * Validate and prepare every fallible part before mutating replay state. - * A malformed event therefore remains the next unread event on every retry - * instead of applying a partial surface mutation twice. - */ - private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void { - let nextHeader = state.header - let nextStepStart = state.stepStart - let nextAnchor = state.anchor - - switch (event.type) { - case 'request/header': - nextHeader = canonicalHeader(event.data.header) - break - case 'request/header-delta': - if (state.header === undefined) { - throw new Error(`token meter: request/header-delta at seq ${event.seq} has no preceding header`) - } - nextHeader = applyHeaderDelta(state.header, event.data) - break - case 'step/start': - if (state.stepStart !== undefined) { - throw new Error( - `token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`, - ) - } - nextStepStart = { ...event.data, surfaceTokens: state.surfaceTokens } - break - case 'step/end': - if (state.stepStart === undefined - || state.stepStart.turn !== event.data.turn - || state.stepStart.step !== event.data.step) { - throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`) - } - nextStepStart = undefined - break - default: - break - } - - const surface = isSurfaceEvent(event) - ? this._prepareSurfaceMutation(session, state, event) - : undefined - - if (event.type === 'assistant/message' && nextHeader?.config.model === this.model) { - const stepStart = state.stepStart - if (stepStart === undefined - || stepStart.turn !== event.data.turn - || stepStart.step !== event.data.step) { - throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`) - } - - // assistant/message is surface-mandatory at every append/seed boundary. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const eventTokens = surface!.tokens - if (event.data.usage !== undefined) { - const providerAssistantTokens = this._estimateProviderAssistant( - session, - event, - eventTokens, - ) - nextAnchor = { - header: nextHeader, - surfaceTokens: stepStart.surfaceTokens + providerAssistantTokens, - baseline: { - kind: 'usage', - tokens: usageTokens(event.data.usage), - usage: event.data.usage, - }, - } - } else { - const anchorSurfaceTokens = stepStart.surfaceTokens + eventTokens - nextAnchor = { - header: nextHeader, - surfaceTokens: anchorSurfaceTokens, - baseline: { - kind: 'estimated', - tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens, - }, - } - } - } - - state.header = nextHeader - state.stepStart = nextStepStart - if (surface !== undefined) surface.commit(state) - state.anchor = nextAnchor - } - - /** Validate one surface operation and return its allocation-light commit. */ - private _prepareSurfaceMutation( - session: Session, - state: ReplayState, - event: SurfaceEvent, - ): PreparedSurfaceMutation { - const tokens = this._estimateSurfaceEvent(session, event) - const op = event.surfaceOp - if (op === 'append') { - return { - tokens, - commit(target) { - target.surface.push({ seq: event.seq, tokens }) - target.surfaceTokens += tokens - }, - } - } - - const startIdx = state.surface.findIndex(node => node.seq === op.start) - const endIdx = state.surface.findIndex(node => node.seq === op.end) - if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { - throw new Error( - `token meter: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, - ) - } - const removedTokens = state.surface - .slice(startIdx, endIdx + 1) - .reduce((total, node) => total + node.tokens, 0) - return { - tokens, - commit(target) { - target.surface.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) - target.surfaceTokens += tokens - removedTokens - }, - } - } - - /** Price one current surface event exactly as it projects to a request. */ - private _estimateSurfaceEvent(session: Session, event: SurfaceEvent): number { - const message = session.deriveEventMessage(event) - return message === null ? 0 : this.estimateMessage(message) - } - - /** - * Reassemble provider output from exact chunk provenance for a usage anchor. - * Missing legacy provenance conservatively treats the durable output as the - * provider output; explicit empty provenance prices a known empty stream. - */ - private _estimateProviderAssistant( - session: Session, - event: SessionEvent<'assistant/message'>, - durableEventTokens: number, - ): number { - const sourceSeqs = event.sourceEventSeqs - if (sourceSeqs === undefined) return durableEventTokens - - const assembler = new BlockAssembler() - const seen = new Set() - for (const seq of sourceSeqs) { - if (seq >= event.seq) { - throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not earlier`) - } - if (seen.has(seq)) { - throw new Error(`token meter: assistant/message at seq ${event.seq} repeats source seq ${seq}`) - } - seen.add(seq) - // Session construction validates contiguous seqs, and the explicit - // earlier-than-assistant check above therefore guarantees existence. - const source = session.events[seq] - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const sourceEvent = source! - if (sourceEvent.type !== 'assistant/chunk') { - throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`) - } - if (sourceEvent.data.turn !== event.data.turn || sourceEvent.data.step !== event.data.step) { - throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} belongs to another step`) - } - assembler.push(sourceEvent.data.chunk) - } - const providerMessage = assembler.message() - return providerMessage.content.length === 0 ? 0 : this.estimateMessage(providerMessage) - } - - /** Price content blocks recursively under this model's density profile. */ - private _estimateContent(blocks: readonly ContentBlock[]): number { - let tokens = 0 - for (const block of blocks) { - switch (block.type) { - case 'text': - case 'reasoning': - tokens += Math.ceil(block.text.length / this.charsPerToken) + BLOCK_OVERHEAD - break - case 'tool-call': - tokens += Math.ceil(block.name.length / this.charsPerToken) - + Math.ceil(block.arguments.length / this.charsPerToken) - + BLOCK_OVERHEAD - break - case 'tool-result': - tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD - break - default: - // ContentBlockMap is merge-extensible; unknown blocks retain a - // conservative structural JSON price under the selected profile. - tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / this.charsPerToken) - } - } - return tokens - } - - /** Price the canonical non-surface request envelope. */ - private _estimateHeader(header: EpochHeader | undefined): number { - if (header === undefined) return 0 - let tokens = 0 - for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message) - if (header.system !== undefined) { - tokens += Math.ceil(header.system.length / this.charsPerToken) + ROLE_OVERHEAD - } - if (header.tools !== undefined && header.tools.length > 0) { - tokens += Math.ceil(JSON.stringify(header.tools).length / this.charsPerToken) + BLOCK_OVERHEAD - } - return tokens - } -} diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index e9b7e812c1..1f2c2f68f2 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -4,21 +4,12 @@ * @module @deepseek-ai/dsh-token-meter/types */ -import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm' -import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' - -/** Optional pricing fields for one configured model. */ -export interface ModelTokenMeterConfig { - /** Provider context-window capacity in tokens. Required for a custom model. */ - contextWindow?: number - /** Heuristic text density in characters per token. Defaults to `4`. */ - charsPerToken?: number -} +import type { TokenUsage } from '@deepseek-ai/dsh-llm' /** Token-meter plugin configuration. */ export interface TokenMeterConfig { - /** Built-in field overrides and custom model profiles, keyed by routed model name. */ - models?: Record + /** Service-wide context-window capacity in tokens. Defaults to `128000`. */ + contextWindow?: number } /** The baseline from which a signed surface delta produces current pressure. */ @@ -29,8 +20,6 @@ export type TokenMeasurementBaseline = /** Detached immutable scalar pressure at one consumed session-log revision. */ export interface TokenMeasurement { - /** Model profile used for every heuristic component. */ - readonly model: string /** Number of durable events consumed; equal to the next unread event seq. */ readonly logRevision: number /** Provider or heuristic anchor used for this measurement. */ @@ -51,8 +40,6 @@ export interface TokenSurfaceNode { /** Detached immutable priced surface at one consumed session-log revision. */ export interface TokenSurfaceMeasurement { - /** Model profile used to price every node. */ - readonly model: string /** Number of durable events consumed; equal to the next unread event seq. */ readonly logRevision: number /** Total heuristic tokens across the current surface. */ @@ -60,42 +47,3 @@ export interface TokenSurfaceMeasurement { /** Current surface nodes in positional head-to-tail order. */ readonly nodes: readonly TokenSurfaceNode[] } - -/** A model-bound replay meter returned by {@link TokenMeterService.resolve}. */ -export interface ModelTokenMeter { - /** Routed model name bound to this handle. */ - readonly model: string - /** Provider context-window capacity in tokens. */ - readonly contextWindow: number - /** Heuristic text density in characters per token. */ - readonly charsPerToken: number - - /** - * Measure current request pressure through the session's durable tail. - * - * Provider usage is reused only when its routed model and canonical request - * envelope match `requestHeader`; otherwise the complete envelope and - * surface are heuristically repriced for this handle's model. - * - * @param session - session to replay through its current durable tail. - * @param requestHeader - optional effective request envelope replacing the latest logged header. - * @returns a detached deeply immutable pressure measurement. - */ - measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement - - /** - * Price the current surface for retention and replacement decisions. - * - * @param session - session to replay through its current durable tail. - * @returns a detached deeply immutable positional surface measurement. - */ - measureSurface(session: Session): TokenSurfaceMeasurement - - /** - * Heuristically price one model-visible message. - * - * @param message - message to price without mutation. - * @returns content and role-framing tokens under this model profile. - */ - estimateMessage(message: Message): number -} diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 1012d5c15e..a91bd392e1 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -4,12 +4,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' import type { EpochHeader } from '@deepseek-ai/dsh-session' -import TokenMeterService, { - TOKEN_METER_INVALID_CONFIG, - TOKEN_METER_MODEL_UNCONFIGURED, - TokenMeterError, -} from '@deepseek-ai/dsh-token-meter' -import type { ModelTokenMeter, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' function header(model: string, extras: Omit = {}): EpochHeader { return canonicalHeader({ config: { model }, ...extras }) @@ -76,69 +72,23 @@ function meter(config: TokenMeterConfig = {}): TokenMeterService { } describe('TokenMeterService configuration and registration', () => { - it('provides immutable zero-config DeepSeek profiles', () => { + it('provides one zero-config context window', () => { const service = meter() - expect(service.resolve('deepseek-v4-flash')).toMatchObject({ - model: 'deepseek-v4-flash', - contextWindow: 128_000, - charsPerToken: 4, - }) - expect(service.resolve('deepseek-v4-pro')).toMatchObject({ - model: 'deepseek-v4-pro', - contextWindow: 128_000, - charsPerToken: 4, - }) + expect(service.contextWindow).toBe(128_000) }) - it('merges built-in overrides field-wise and defaults custom density', () => { - const service = meter({ - models: { - 'deepseek-v4-flash': { charsPerToken: 2 }, - custom: { contextWindow: 32_000 }, - }, - }) - expect(service.resolve('deepseek-v4-flash')).toMatchObject({ contextWindow: 128_000, charsPerToken: 2 }) - expect(service.resolve('deepseek-v4-pro')).toMatchObject({ contextWindow: 128_000, charsPerToken: 4 }) - expect(service.resolve('custom')).toMatchObject({ contextWindow: 32_000, charsPerToken: 4 }) - }) - - it('throws a typed exact-code error for unknown models', () => { - const service = meter() - let thrown: unknown - try { - service.resolve('unconfigured-model') - } catch (error: unknown) { - thrown = error - } - expect(thrown).toBeInstanceOf(TokenMeterError) - expect(thrown).toMatchObject({ - code: TOKEN_METER_MODEL_UNCONFIGURED, - model: 'unconfigured-model', - }) - expect((thrown as Error).message).toContain('unconfigured-model') + it('accepts one service-wide context-window override', () => { + expect(meter({ contextWindow: 32_000 }).contextWindow).toBe(32_000) }) it.each([ - [{ models: null }, /models must be an object/], - [{ models: [] }, /models must be an object/], - [{ models: { custom: {} } }, /requires contextWindow/], - [{ models: { '': { contextWindow: 1 } } }, /must not be empty/], - [{ models: { custom: { contextWindow: 0 } } }, /positive integer/], - [{ models: { custom: { contextWindow: 1.5 } } }, /positive integer/], - [{ models: { custom: { contextWindow: 1, charsPerToken: 0 } } }, /positive finite/], - [{ models: { custom: { contextWindow: 1, charsPerToken: Number.NaN } } }, /positive finite/], - [{ models: { custom: null } }, /must be an object/], - [{ models: { custom: [] } }, /must be an object/], - ] as unknown as Array<[TokenMeterConfig, RegExp]>)('rejects invalid profile config %#', (config, pattern) => { - let thrown: unknown - try { - meter(config) - } catch (error: unknown) { - thrown = error - } - expect(thrown).toBeInstanceOf(TokenMeterError) - expect(thrown).toMatchObject({ code: TOKEN_METER_INVALID_CONFIG }) - expect((thrown as Error).message).toMatch(pattern) + { contextWindow: 0 }, + { contextWindow: -1 }, + { contextWindow: 1.5 }, + { contextWindow: Number.NaN }, + { contextWindow: null }, + ] as unknown as TokenMeterConfig[])('rejects invalid context capacity %#', (config) => { + expect(() => meter(config)).toThrow(/contextWindow .* positive integer/) }) it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => { @@ -151,9 +101,9 @@ describe('TokenMeterService configuration and registration', () => { }) }) -describe('ModelTokenMeter pricing', () => { - it('prices every built-in content shape and merge-extended blocks', () => { - const handle = meter({ models: { custom: { contextWindow: 100, charsPerToken: 2 } } }).resolve('custom') +describe('TokenMeterService pricing', () => { + it('prices every built-in content shape and merge-extended blocks with one fixed heuristic', () => { + const service = meter({ contextWindow: 100 }) const blocks: ContentBlock[] = [ { type: 'text', text: 'abcd' }, { type: 'reasoning', text: 'ab' }, @@ -166,17 +116,16 @@ describe('ModelTokenMeter pricing', () => { }, { type: 'future-block', payload: 'abcd' } as unknown as ContentBlock, ] - const estimated = handle.estimateMessage({ role: 'assistant', content: blocks }) + const estimated = service.estimateMessage({ role: 'assistant', content: blocks }) expect(estimated).toBeGreaterThan(30) - expect(handle.estimateMessage(textMessage('abcd'))).toBe(10) + expect(service.estimateMessage(textMessage('abcd'))).toBe(9) }) it('returns a detached deeply immutable empty measurement', () => { - const handle = meter().resolve('deepseek-v4-flash') + const service = meter() const session = new Session(SessionId('empty')) - const result = handle.measure(session) + const result = service.measure(session) expect(result).toEqual({ - model: 'deepseek-v4-flash', logRevision: 0, baseline: { kind: 'none', tokens: 0 }, surfaceDeltaTokens: 0, @@ -190,14 +139,14 @@ describe('ModelTokenMeter pricing', () => { }) it('keeps earlier scalar and surface snapshots detached from later replay', () => { - const handle = meter().resolve('deepseek-v4-flash') + const service = meter() const session = new Session(SessionId('detached')) session.append('user/message', { content: [{ type: 'text', text: 'first' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - const scalar = handle.measure(session) - const surface = handle.measureSurface(session) + const scalar = service.measure(session) + const surface = service.measureSurface(session) const scalarCopy = structuredClone(scalar) const surfaceCopy = structuredClone(surface) @@ -205,8 +154,8 @@ describe('ModelTokenMeter pricing', () => { content: [{ type: 'text', text: 'second' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - expect(handle.measure(session).logRevision).toBe(2) - expect(handle.measureSurface(session).nodes).toHaveLength(2) + expect(service.measure(session).logRevision).toBe(2) + expect(service.measureSurface(session).nodes).toHaveLength(2) expect(scalar).toEqual(scalarCopy) expect(surface).toEqual(surfaceCopy) expect(scalar.logRevision).toBe(1) @@ -214,7 +163,7 @@ describe('ModelTokenMeter pricing', () => { }) it('prices header, prefix, tools, and surface when no reusable usage exists', () => { - const handle = meter().resolve('deepseek-v4-flash') + const service = meter() const session = new Session(SessionId('heuristic')) session.append('user/message', { content: [{ type: 'text', text: 'question' }], @@ -225,9 +174,9 @@ describe('ModelTokenMeter pricing', () => { messagePrefix: [textMessage('prefix')], tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }], })) - const result = handle.measure(session) + const result = service.measure(session) expect(result.baseline.kind).toBe('estimated') - expect(result.totalTokens).toBeGreaterThan(handle.measureSurface(session).totalTokens) + expect(result.totalTokens).toBeGreaterThan(service.measureSurface(session).totalTokens) expect(result.logRevision).toBe(session.events.length) }) }) @@ -242,7 +191,7 @@ describe('replay anchors and surface folds', () => { } it('uses disjoint provider usage and signed durable-output rewrites', () => { - const handle = meter().resolve('deepseek-v4-flash') + const service = meter() const session = new Session(SessionId('usage')) session.append('user/message', { content: [{ type: 'text', text: 'before' }], @@ -253,7 +202,7 @@ describe('replay anchors and surface folds', () => { durableText: 'a much longer rewritten durable assistant answer', usage: USAGE, }) - const result = handle.measure(session) + const result = service.measure(session) expect(result.baseline).toMatchObject({ kind: 'usage', tokens: 34, usage: USAGE }) expect(result.surfaceDeltaTokens).toBeGreaterThan(0) expect(result.totalTokens).toBe(34 + result.surfaceDeltaTokens) @@ -263,20 +212,20 @@ describe('replay anchors and surface folds', () => { }) it('uses an estimated anchor when provider usage is absent', () => { - const handle = meter().resolve('deepseek-v4-flash') + const service = meter() const session = new Session(SessionId('missing-usage')) appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), { providerText: 'provider', durableText: 'rewritten', }) - const anchored = handle.measure(session) + const anchored = service.measure(session) expect(anchored.baseline.kind).toBe('estimated') expect(anchored.surfaceDeltaTokens).toBe(0) session.append('user/message', { content: [{ type: 'text', text: 'later' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - const advanced = handle.measure(session) + const advanced = service.measure(session) expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0) }) @@ -295,24 +244,17 @@ describe('replay anchors and surface folds', () => { usage: USAGE, provenance: 'absent', }) - const handle = meter().resolve('deepseek-v4-flash') - expect(handle.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0) - expect(handle.measure(legacy).surfaceDeltaTokens).toBe(0) + const service = meter() + expect(service.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0) + expect(service.measure(legacy).surfaceDeltaTokens).toBe(0) }) - it('preserves one model anchor across another model success and reuses it after switching back', () => { - const service = meter({ - models: { - alpha: { contextWindow: 1000 }, - beta: { contextWindow: 1000, charsPerToken: 2 }, - }, - }) - const alpha = service.resolve('alpha') - const beta = service.resolve('beta') + it('keeps only the latest successful request anchor across model switches', () => { + const service = meter({ contextWindow: 1_000 }) const session = new Session(SessionId('switch')) const alphaHeader = header('alpha', { system: 'same envelope' }) appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' }) - expect(alpha.measure(session).baseline.kind).toBe('usage') + expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 34 }) appendSuccessfulCall(session, header('beta'), { turn: 1, @@ -320,34 +262,33 @@ describe('replay anchors and surface folds', () => { usage: { inputTokens: 100, outputTokens: 50 }, providerText: 'beta response', }) - expect(alpha.measure(session).baseline.kind).toBe('estimated') - expect(beta.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 150 }) + expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 150 }) appendHeader(session, alphaHeader) - const switchedBack = alpha.measure(session) - expect(switchedBack.baseline).toMatchObject({ kind: 'usage', tokens: 34 }) - expect(switchedBack.surfaceDeltaTokens).toBeGreaterThan(0) + const switchedBack = service.measure(session) + expect(switchedBack.baseline.kind).toBe('estimated') + expect(switchedBack.surfaceDeltaTokens).toBe(0) }) it('invalidates usage for any canonical envelope change or explicit override', () => { - const handle = meter().resolve('deepseek-v4-flash') + const service = meter() const session = new Session(SessionId('envelope')) const anchoredHeader = header('deepseek-v4-flash', { system: 'one' }) appendSuccessfulCall(session, anchoredHeader, { usage: USAGE }) - expect(handle.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage') - expect(handle.measure(session, header('deepseek-v4-flash', { system: 'two' })).baseline.kind) + expect(service.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage') + expect(service.measure(session, header('deepseek-v4-flash', { system: 'two' })).baseline.kind) .toBe('estimated') - expect(handle.measure(session, header('deepseek-v4-pro', { system: 'one' })).baseline.kind) + expect(service.measure(session, header('deepseek-v4-pro', { system: 'one' })).baseline.kind) .toBe('estimated') - expect(handle.measure(session, { + expect(service.measure(session, { ...anchoredHeader, config: { ...anchoredHeader.config, temperature: 0.2 }, }).baseline.kind).toBe('estimated') - expect(handle.measure(session, { + expect(service.measure(session, { ...anchoredHeader, messagePrefix: [textMessage('prefix')], }).baseline.kind).toBe('estimated') - expect(handle.measure(session, { + expect(service.measure(session, { ...anchoredHeader, tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }], }).baseline.kind).toBe('estimated') @@ -357,7 +298,7 @@ describe('replay anchors and surface folds', () => { const session = new Session(SessionId('header-delta')) appendHeader(session, header('deepseek-v4-flash')) session.append('request/header-delta', { config: { model: 'deepseek-v4-pro' } }) - const result = meter().resolve('deepseek-v4-flash').measure(session) + const result = meter().measure(session) expect(result.baseline.kind).toBe('estimated') expect(result.logRevision).toBe(2) }) @@ -374,9 +315,8 @@ describe('replay anchors and surface folds', () => { source: { kind: 'user' }, }, { surfaceOp: 'append' }) const seeded = new Session(SessionId('surface-seeded'), original.events) - const handle = service.resolve('deepseek-v4-flash') - const before = handle.measureSurface(seeded) - const beforeScalar = handle.measure(seeded) + const before = service.measureSurface(seeded) + const beforeScalar = service.measure(seeded) expect(before.nodes).toHaveLength(2) expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0) @@ -385,8 +325,8 @@ describe('replay anchors and surface folds', () => { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] }) - const after = handle.measureSurface(seeded) - const afterScalar = handle.measure(seeded) + const after = service.measureSurface(seeded) + const afterScalar = service.measure(seeded) expect(after.nodes).toHaveLength(2) expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1) expect(after.logRevision).toBe(seeded.events.length) @@ -405,7 +345,7 @@ describe('replay anchors and surface folds', () => { durableText: '', provenance: 'empty', }) - const surface = meter().resolve('deepseek-v4-flash').measureSurface(session) + const surface = meter().measureSurface(session) const assistant = session.events.find(event => event.type === 'assistant/message')! expect(surface.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }]) expect(surface.totalTokens).toBe(0) @@ -413,18 +353,18 @@ describe('replay anchors and surface folds', () => { }) describe('malformed replay and listener lifecycle', () => { - function expectRepeatedFailure(handle: ModelTokenMeter, session: Session, pattern: RegExp): void { - expect(() => handle.measure(session)).toThrow(pattern) - expect(() => handle.measure(session)).toThrow(pattern) + function expectRepeatedFailure(service: TokenMeterService, session: Session, pattern: RegExp): void { + expect(() => service.measure(session)).toThrow(pattern) + expect(() => service.measure(session)).toThrow(pattern) } it('rejects a header delta before any snapshot transactionally', () => { const session = new Session(SessionId('bad-delta')) session.append('request/header-delta', { config: { model: 'deepseek-v4-flash' } }) - expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no preceding header/) + expectRepeatedFailure(meter(), session, /no preceding header/) }) - it('rejects a matching-model assistant without its step boundary transactionally', () => { + it('rejects an assistant without its step boundary transactionally', () => { const session = new Session(SessionId('bad-step')) appendHeader(session, header('deepseek-v4-flash')) session.append('assistant/message', { @@ -432,7 +372,7 @@ describe('malformed replay and listener lifecycle', () => { step: 1, content: [{ type: 'text', text: 'bad' }], }, { surfaceOp: 'append', sourceEventSeqs: [] }) - expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no matching step\/start/) + expectRepeatedFailure(meter(), session, /no matching step\/start/) }) it('clears completed step boundaries and rejects overlapping or late step events', () => { @@ -440,7 +380,7 @@ describe('malformed replay and listener lifecycle', () => { overlapping.append('step/start', { turn: 1, step: 1 }) overlapping.append('step/start', { turn: 1, step: 2 }) expectRepeatedFailure( - meter().resolve('deepseek-v4-flash'), + meter(), overlapping, /arrived before turn 1\/step 1 ended/, ) @@ -455,7 +395,7 @@ describe('malformed replay and listener lifecycle', () => { content: [], }, { surfaceOp: 'append', sourceEventSeqs: [] }) expectRepeatedFailure( - meter().resolve('deepseek-v4-flash'), + meter(), late, /no matching step\/start/, ) @@ -464,7 +404,7 @@ describe('malformed replay and listener lifecycle', () => { mismatchedEnd.append('step/start', { turn: 1, step: 1 }) mismatchedEnd.append('step/end', { turn: 1, step: 2 }) expectRepeatedFailure( - meter().resolve('deepseek-v4-flash'), + meter(), mismatchedEnd, /step\/end .* no matching step\/start/, ) @@ -509,7 +449,7 @@ describe('malformed replay and listener lifecycle', () => { content: [{ type: 'text', text: 'bad' }], usage: { inputTokens: 1, outputTokens: 1 }, }, { surfaceOp: 'append', sourceEventSeqs }) - expect(() => meter().resolve('deepseek-v4-flash').measure(session)).toThrow(testCase.pattern) + expect(() => meter().measure(session)).toThrow(testCase.pattern) } }) @@ -528,7 +468,7 @@ describe('malformed replay and listener lifecycle', () => { content: [], usage: { inputTokens: 1, outputTokens: 0 }, }, { surfaceOp: 'append', sourceEventSeqs: [source, source] }) - expect(() => meter().resolve('deepseek-v4-flash').measure(duplicate)).toThrow(/repeats source seq/) + expect(() => meter().measure(duplicate)).toThrow(/repeats source seq/) const future = new Session(SessionId('future-source')) future.append('step/start', { turn: 1, step: 1 }) @@ -539,7 +479,7 @@ describe('malformed replay and listener lifecycle', () => { content: [], usage: { inputTokens: 1, outputTokens: 0 }, }, { surfaceOp: 'append', sourceEventSeqs: [99] }) - expect(() => meter().resolve('deepseek-v4-flash').measure(future)).toThrow(/is not earlier/) + expect(() => meter().measure(future)).toThrow(/is not earlier/) }) it('does not partially apply a malformed assistant replacement', () => { @@ -556,7 +496,7 @@ describe('malformed replay and listener lifecycle', () => { content: [{ type: 'text', text: 'replacement' }], }, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] }) expectRepeatedFailure( - meter().resolve('deepseek-v4-flash'), + meter(), session, /no matching step\/start/, ) @@ -572,32 +512,32 @@ describe('malformed replay and listener lifecycle', () => { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, }, { surfaceOp: { op: 'replace', start: 99, end: 99 }, sourceEventSeqs: [0] }) - expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /invalid current range/) + expectRepeatedFailure(meter(), session, /invalid current range/) }) it('handles earlier-reader catch-up, eager observation, and service reload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - let handle: ModelTokenMeter | undefined + let activeMeter: TokenMeterService | undefined const revisions: number[] = [] ctx.on('session/event', (session) => { - if (handle !== undefined) revisions.push(handle.measure(session).logRevision) + if (activeMeter !== undefined) revisions.push(activeMeter.measure(session).logRevision) }) const firstFiber = await ctx.plugin(TokenMeterService) - handle = ctx.tokenMeter.resolve('deepseek-v4-flash') + activeMeter = ctx.tokenMeter const session = ctx.sessions.create(SessionId('listener-order')) - handle.measure(session) + activeMeter.measure(session) session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) expect(revisions).toEqual([1]) - expect(handle.measure(session).logRevision).toBe(1) + expect(activeMeter.measure(session).logRevision).toBe(1) await firstFiber.dispose() const secondFiber = await ctx.plugin(TokenMeterService) - handle = ctx.tokenMeter.resolve('deepseek-v4-flash') - expect(handle.measure(session).logRevision).toBe(1) + activeMeter = ctx.tokenMeter + expect(activeMeter.measure(session).logRevision).toBe(1) await secondFiber.dispose() }) }) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1152bb8270..994edaa8bb 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -92,7 +92,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Replay token measurement', mode: 'core', consumers: ['compact-basic'], - note: 'Owns isolated per-model/session replay folds; pressure consumers share immutable revisioned measurements.', + note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.', }, { key: 'sessions', From 19a56ec542f30c07f4bb5ec419a91de80c107948 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 13:23:20 +0800 Subject: [PATCH 19/27] fix(token-meter): reject stale config (round 2) --- docs/cordis-catalog/services.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 2 +- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/src/config.ts | 23 +++++ .../compact-basic/tests/compact-basic.spec.ts | 2 + .../tests/loader-composition.spec.ts | 91 +++++++++++++------ packages/llm/token-meter/README.md | 2 +- packages/llm/token-meter/src/index.ts | 15 +++ .../llm/token-meter/tests/token-meter.spec.ts | 5 + 9 files changed, 110 insertions(+), 34 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5805ae130d..d57f0ed9c5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -273,7 +273,7 @@ estimateMessage(message: Message): number Types: [Message](../core-data-structures/core.md) -Source: [`packages/llm/token-meter/src/index.ts:92`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:107`](../../packages/llm/token-meter/src/index.ts) ## `ctx.tools` — `ToolRegistry` 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 77f836e8e3..341e5f2866 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 @@ -28,7 +28,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" ### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend -An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the standalone service lets multiple consumers share one model/session replay fold. +An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index b166636a5f..d537848bd2 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -20,7 +20,7 @@ This backend owns the compaction policy: ## Config (`BasicCompactConfig`) -Every setting is optional. The pressure and retention policy applies to the token meter's single context window. +Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected. | Key | Required | Meaning | |---|---|---| diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index da2cd7bea7..377bd21523 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -14,6 +14,28 @@ const DEFAULT_THRESHOLD_RATIO = 0.8 /** Default verbatim-tail fraction of the token meter's context window. */ const DEFAULT_RETAIN_RATIO = 0.16 +/** Complete public configuration key set. */ +const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet = new Set([ + 'thresholdRatio', + 'retainTokens', + 'summarizationModel', + 'maxTokens', + 'compactionRetries', + 'auto', +]) + +/** Reject stale or misspelled keys before defaults can hide them. */ +function validateConfigKeys(config: BasicCompactConfig): void { + for (const key of Object.keys(config)) { + if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) { + throw new Error( + `BasicCompactConfig: unknown key "${key}" ` + + '(allowed: thresholdRatio, retainTokens, summarizationModel, maxTokens, compactionRetries, auto)', + ) + } + } +} + /** * Resolve defaults and validate the service-wide compaction policy. * @param config - raw compact-basic configuration. @@ -24,6 +46,7 @@ export function resolveConfig( config: BasicCompactConfig = {}, tokenMeter: TokenMeterService, ): ResolvedConfig { + validateConfigKeys(config) const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO const retainTokens = config.retainTokens ?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 86471d2630..ffc60ef024 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -163,6 +163,8 @@ describe('compact configuration and defaults', () => { [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/], [{ retainTokens: -1 }, /non-negative integer/], [{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/], + [{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/], + [{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/], ] as Array<[unknown, RegExp]> for (const [config, pattern] of bad) { diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index 26ff37a8e6..b13e8f8b67 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -20,44 +20,75 @@ afterEach(async () => { root = undefined }) +async function loadYaml(lines: readonly string[]): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [...lines, ''].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmService], + ['@deepseek-ai/dsh-token-meter', TokenMeterService], + ['@deepseek-ai/dsh-compact-basic', BasicCompactService], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return context +} + describe('real Loader composition', () => { - it('loads the zero-config token-meter then compact-basic YAML pair', async () => { - root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-')) - const configPath = join(root, 'cordis.yml') - await writeFile(configPath, [ + it('loads the flat token-meter and compact-basic YAML shape', async () => { + const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-token-meter'", + ' config:', + ' contextWindow: 4096', "- name: '@deepseek-ai/dsh-compact-basic'", - '', - ].join('\n')) - - context = new Context() - context.baseUrl = pathToFileURL(root).href + '/' - await context.plugin(Loader) - context.loader.builtins.include = Include - const modules = new Map([ - ['@deepseek-ai/dsh-llm', LlmService], - ['@deepseek-ai/dsh-token-meter', TokenMeterService], - ['@deepseek-ai/dsh-compact-basic', BasicCompactService], + ' config:', + ' thresholdRatio: 0.5', + ' retainTokens: 512', + ' auto: false', ]) - context.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable - await context.loader.create({ - name: 'cordis:include', - config: { path: pathToFileURL(configPath).href }, - }) - await context.loader.await() - const unloaded = [...context.loader.entries()] + const unloaded = [...loaded.loader.entries()] .filter(entry => entry.fiber === undefined && !entry.disabled) .map(entry => entry.options.name) expect(unloaded).toEqual([]) - expect(context.tokenMeter.contextWindow).toBe(128_000) - expect(context.get('compact')).toBeInstanceOf(BasicCompactService) + expect(loaded.tokenMeter.contextWindow).toBe(4096) + expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService) + expect((loaded.compact as BasicCompactService).config).toMatchObject({ + thresholdRatio: 0.5, + retainTokens: 512, + auto: false, + }) + }) + + it('rejects stale token-meter config after Schemastery normalization', async () => { + context = new Context() + await expect(context.plugin(TokenMeterService, { + models: { legacy: { contextWindow: 4096 } }, + } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/) + }) + + it('rejects stale compact-basic config after Schemastery normalization', async () => { + context = new Context() + await context.plugin(LlmService) + await context.plugin(TokenMeterService) + await expect(context.plugin(BasicCompactService, { + models: { legacy: { thresholdRatio: 0.5 } }, + } as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/) }) }) diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 85f14aed35..3beaff1506 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -8,7 +8,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I |---|---:|---| | `contextWindow` | `128000` | Positive integer service-wide context capacity. | -The estimator intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. `contextWindow` is the only deployment setting. Direct construction validates it; Loader mounts first apply the package's Schemastery shape validation. +The estimator intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. `contextWindow` is the only deployment setting. Direct construction validates it; Loader mounts first apply the package's Schemastery shape validation. Unrecognized top-level keys are rejected. ## Measurement contract diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 0b5b3b062d..aa88831033 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -23,6 +23,9 @@ export type * from './types.ts' /** Default service-wide provider context capacity. */ const DEFAULT_CONTEXT_WINDOW = 128_000 +/** Complete public configuration key set. */ +const TOKEN_METER_CONFIG_KEYS: ReadonlySet = new Set(['contextWindow']) + /** Fixed text-density estimate used until exact tokenization is needed. */ const CHARS_PER_TOKEN = 4 @@ -69,8 +72,20 @@ function optionalHeaderEquals( return headerEquals(left, right) } +/** Reject stale or misspelled keys before defaults can hide them. */ +function validateConfigKeys(config: TokenMeterConfig): void { + for (const key of Object.keys(config)) { + if (!TOKEN_METER_CONFIG_KEYS.has(key)) { + throw new Error( + `TokenMeterConfig: unknown key "${key}" (allowed: contextWindow)`, + ) + } + } +} + /** Resolve and validate the one service-wide context capacity. */ function resolveContextWindow(config: TokenMeterConfig): number { + validateConfigKeys(config) const contextWindow = config.contextWindow === undefined ? DEFAULT_CONTEXT_WINDOW : config.contextWindow diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index a91bd392e1..648eef01f5 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -81,6 +81,11 @@ describe('TokenMeterService configuration and registration', () => { expect(meter({ contextWindow: 32_000 }).contextWindow).toBe(32_000) }) + it.each(['models', 'contextWidow'])('rejects unknown top-level config key %s', (key) => { + expect(() => meter({ [key]: {} })) + .toThrow(`TokenMeterConfig: unknown key "${key}"`) + }) + it.each([ { contextWindow: 0 }, { contextWindow: -1 }, From 6d96b3e4a85ca2c14ae68d6f234dbb8def456fe0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 14:38:24 +0800 Subject: [PATCH 20/27] refactor(token-meter): merge measurement snapshots (round 1) --- docs/cordis-catalog/services.md | 3 +- docs/core-data-structures/token-meter.md | 21 ++--- ...07-15-replay-token-meter-service.i18n.yaml | 4 +- .../2026-07-15-replay-token-meter-service.md | 11 ++- ...026-07-15-replay-token-meter-service.zh.md | 11 ++- packages/compact/compact-basic/src/index.ts | 8 +- packages/compact/compact-basic/src/region.ts | 16 ++-- .../compact-basic/tests/compact-basic.spec.ts | 22 +++-- .../cordis/tool-cordis/src/api-catalog.ts | 7 +- packages/llm/token-meter/README.md | 8 +- packages/llm/token-meter/src/index.ts | 23 ++--- packages/llm/token-meter/src/types.ts | 16 ++-- .../llm/token-meter/tests/token-meter.spec.ts | 83 ++++++++++++++----- scripts/type-equiv.manifest.json | 1 - 14 files changed, 119 insertions(+), 115 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d57f0ed9c5..7bccc15778 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -267,13 +267,12 @@ Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement -measureSurface(session: Session): TokenSurfaceMeasurement estimateMessage(message: Message): number ``` Types: [Message](../core-data-structures/core.md) -Source: [`packages/llm/token-meter/src/index.ts:107`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-meter/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/token-meter.md b/docs/core-data-structures/token-meter.md index eee01c27b8..a6e70f56f6 100644 --- a/docs/core-data-structures/token-meter.md +++ b/docs/core-data-structures/token-meter.md @@ -1,6 +1,6 @@ # Token Meter -`@deepseek-ai/dsh-token-meter` exposes detached replay measurements for request pressure and positional surface pricing. Scalar and surface snapshots carry the number of durable events consumed as `logRevision`; consumers compare revisions before making a joint decision. +`@deepseek-ai/dsh-token-meter` exposes one detached replay snapshot for request pressure and positional surface pricing. `logRevision` is the number of durable events consumed for every field in the measurement. Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts) @@ -16,10 +16,14 @@ interface TokenMeasurement { readonly surfaceDeltaTokens: number /** Non-negative current request-and-response pressure. */ readonly totalTokens: number + /** Total heuristic tokens across the current surface. */ + readonly surfaceTokens: number + /** Current surface nodes in positional head-to-tail order. */ + readonly nodes: readonly TokenSurfaceNode[] } ``` -`baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope. `estimated` means the service priced the complete envelope and surface with its fixed heuristic. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor. +`baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope. `estimated` means the service priced the complete envelope and surface with its fixed heuristic. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor. `totalTokens` remains request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of the node prices. ## `TokenSurfaceNode` @@ -32,17 +36,4 @@ interface TokenSurfaceNode { } ``` -## `TokenSurfaceMeasurement` - -```ts type-equiv -interface TokenSurfaceMeasurement { - /** Number of durable events consumed; equal to the next unread event seq. */ - readonly logRevision: number - /** Total heuristic tokens across the current surface. */ - readonly totalTokens: number - /** Current surface nodes in positional head-to-tail order. */ - readonly nodes: readonly TokenSurfaceNode[] -} -``` - Surface order is authoritative; replacement nodes can have higher durable seqs than later positional nodes. The snapshot is immutable and does not grow when the underlying replay fold advances. diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index afd99ac686..cb5affaaf7 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 981e789a51bbe7e2b09d47a76d71ac79fe14c999 -2026-07-15-replay-token-meter-service.zh.md: 85565b6f3db77ab81712f9c128e570c7a16bae8d +2026-07-15-replay-token-meter-service.md: 5a3ffe61bef73eeffd3441291d3ae997f0e092fd +2026-07-15-replay-token-meter-service.zh.md: e6a2e1163ac0803cd87b917f9d2a14ad2372c2f0 diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md index 981e789a51..5a3ffe61be 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -14,7 +14,7 @@ Provider usage is not a complete answer. It describes one successful call under ### One concrete LLM-family service -`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `contextWindow`, `measure(session, requestHeader?)`, `measureSurface(session)`, and `estimateMessage(message)`; consumers call the singleton service directly. +`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `contextWindow`, `measure(session, requestHeader?)`, and `estimateMessage(message)`; consumers call the singleton service directly. The service has one `contextWindow`, defaulting to 128,000 tokens and configurable as a positive integer. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, density settings, tokenizer backends, or language-specific strategies. @@ -22,7 +22,7 @@ The service has one `contextWindow`, defaulting to 128,000 tokens and configurab Each session owns one isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical request headers and deltas, step boundaries, surface appends and replacements, assistant usage, and assistant-chunk provenance. A malformed next event fails transactionally and remains unread rather than partially mutating state. -`measure(session, requestHeader?)` returns scalar pressure. `measureSurface(session)` returns positional per-node prices for retention and replacement decisions. `estimateMessage(message)` applies the fixed heuristic without session state. Results are detached, deeply immutable snapshots carrying `logRevision`; a consumer compares scalar and surface revisions before making one decision. +`measure(session, requestHeader?)` synchronizes the fold once and returns scalar pressure together with positional per-node prices. `totalTokens` remains request-and-response pressure; `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override changes pressure pricing only, while the surface fields always describe the current session. `estimateMessage(message)` applies the fixed heuristic without session state. Each result is one detached, deeply immutable snapshot carrying one `logRevision`. Every measurement clones the current nodes and is therefore O(surface). Provider usage is reused only when the measured canonical request envelope equals the latest successful-call anchor. Any model, system, prefix, tool, or call-config change causes complete heuristic repricing. Surface changes remain a signed delta from a matching anchor, including negative values after a shrinking replacement. A later successful request replaces the earlier anchor, including across model switches. @@ -32,20 +32,22 @@ Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reas `dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The singleton service consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection. +Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization; any intervening durable append changes `logRevision` and prevents replacement. + Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, summarization model `''`, maximum summary output `8192`, one extra compaction attempt, and automatic triggering enabled. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. Empty summarization model resolves the latest logged routed model, then `AgentOptions.model`. The pre-step trigger measures a provisional envelope: the current prompt and prefix override logged values, while the latest logged header supplies model, tools, and other call config. A model-less router-only agent skips that provisional check because `agent/request` can route later; any routed model name can use the singleton estimator. ## Testing -Unit coverage pins service configuration, fixed estimation, envelope invalidation, latest-anchor replacement across model switches, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, immutable snapshots, listener ordering, reload, compact defaults, routing fallback, retention, convergence, and transaction rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order. +Unit coverage pins service configuration, fixed estimation, envelope invalidation, latest-anchor replacement across model switches, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, unified snapshot detachment and deep immutability, surface-total equality, listener ordering, reload, compact defaults, routing fallback, one-call automatic decisions, retention, convergence, and log-revision rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order. ## Alternatives considered - **Keep estimation inside `CompactService`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API. - **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration. - **Keep model-keyed windows and density profiles** — rejected because the deployment currently has one context policy and one estimator. Model registries, unknown-model failures, and configurable density add branches without a second behavior to select. -- **Copy complete history into each scalar result** — rejected because below-threshold reads are common. Immutable revisioned scalars and a separate surface snapshot preserve consistency without an O(history) copy. +- **Keep separate scalar and surface measurements** — rejected because callers would need two reads and revision matching for one decision. A scalar-only read could avoid cloning nodes below threshold, but the split API introduces a caller-side race window; the unified snapshot accepts O(surface) cloning in exchange for coherence. - **Treat provider usage as portable between envelopes** — rejected because model, tools, prefixes, and call config are request facts. Mismatch reprices the whole current request. ## Consequences @@ -53,5 +55,6 @@ Unit coverage pins service configuration, fixed estimation, envelope invalidatio - Token pressure has one replay-aware owner that compaction and future plugins can share. - The default makes the bundled composition usable with two zero-config plugin entries; deployments override one context capacity when needed. - Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer. +- Every measurement clones the current positional surface and therefore costs O(surface), including pressure checks that finish below threshold. - Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure. - The pre-step compact integration can skip a router-only first check and can miss tool or routing changes applied later in request middleware. diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 85565b6f3d..e6a2e1163a 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 一个具体的 LLM 家族服务 -`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `contextWindow`、`measure(session, requestHeader?)`、`measureSurface(session)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 +`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `contextWindow`、`measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 服务只有一个 `contextWindow`,默认值为 128,000 token,并允许配置为正整数。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、密度设置、分词器后端或语言专用策略。 @@ -22,7 +22,7 @@ Status: implemented 每个会话都有一个隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范请求头及其增量、步骤边界、表层追加与替换、assistant usage,以及 assistant 分片来源。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。 -`measure(session, requestHeader?)` 返回标量压力。`measureSurface(session)` 返回用于保留与替换决策的逐位置节点价格。`estimateMessage(message)` 不依赖会话状态,直接应用固定启发式规则。结果是分离且深度不可变的快照,并携带 `logRevision`;消费方在一次联合决策前比较标量与表层修订号。 +`measure(session, requestHeader?)` 只同步一次折叠,并在返回标量压力的同时给出逐位置节点价格。`totalTokens` 仍表示请求与响应压力;`surfaceTokens` 是仅针对表层的启发式总量,并等于 `nodes[].tokens` 之和。`requestHeader` 覆盖只改变压力定价,表层字段始终描述当前会话。`estimateMessage(message)` 不依赖会话状态,直接应用固定启发式规则。每个结果都是一个分离且深度不可变的快照,只携带一个 `logRevision`。每次计量都会复制当前节点,因此成本为 O(surface)。 只有当待计量的规范请求信封等于最近一次成功调用的锚点时,服务才复用提供方 usage。模型、系统提示词、前缀、工具或调用配置任一变化都会触发完整的启发式重新定价。表层变化相对匹配锚点保留有符号增量,包括缩小替换后的负值。后续成功请求会替换先前锚点,模型切换时也一样。 @@ -32,20 +32,22 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket `dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。单例服务一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。 +自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务先追加持久 `compact/start` 锁,再执行一次计量,并在异步摘要完成后再次计量;期间任何持久追加都会改变 `logRevision`,从而阻止替换。 + 压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、摘要模型 `''`、摘要最大输出 `8192`、一次额外压缩尝试,以及启用自动触发。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。空摘要模型先解析最近记录的实际路由模型,再使用 `AgentOptions.model`。 pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日志值,最近记录的请求头提供模型、工具及其他调用配置。没有模型的纯路由 agent(智能体)会跳过该临时检查,因为 `agent/request` 仍可稍后路由;任意路由模型名都可使用这个单例估算器。 ## 测试 -单元覆盖固定服务配置、固定估算、信封失效、模型切换时替换最新锚点、有无 usage 的路径、种子追加/替换回放、有符号增量、来源模式、畸形边界、不可变快照、监听器顺序、重载、压缩默认值、路由回退、保留、收敛与事务回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic 包名称。 +单元覆盖固定服务配置、固定估算、信封失效、模型切换时替换最新锚点、有无 usage 的路径、种子追加/替换回放、有符号增量、来源模式、畸形边界、统一快照的分离性与深度不可变性、表层总量相等性、监听器顺序、重载、压缩默认值、路由回退、自动决策单次调用、保留、收敛与日志修订回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic 包名称。 ## 考虑过的替代方案 - **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。 - **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的包与配置。 - **保留模型键控的窗口与密度 profile**——不予采纳,因为当前部署只有一种上下文策略与一个估算器。模型注册表、未知模型错误和可配置密度只增加分支,却没有第二种行为可供选择。 -- **在每个标量结果中复制完整历史**——不予采纳,因为低于阈值的读取很常见。不可变且带修订号的标量与独立表层快照,在不进行 O(history) 复制的情况下保持一致性。 +- **保留独立的标量与表层计量**——不予采纳,因为消费方必须为一次决策执行两次读取并匹配修订号。仅读取标量可以避免在低于阈值时复制节点,但拆分 API 会在消费方引入竞态窗口;统一快照接受 O(surface) 复制成本,以换取结果一致性。 - **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。 ## 后果 @@ -53,5 +55,6 @@ pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日 - Token 压力拥有一个可供压缩与未来插件共享的回放感知所有者。 - 默认值让内置组合只需两个零配置插件条目即可使用;部署需要时只覆盖一个上下文容量。 - 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。 +- 每次计量都会复制当前的位置表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。 - 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。 - pre-step 压缩集成可能跳过纯路由的首次检查,也可能错过请求中间件稍后应用的工具或路由变化。 diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 16387c5e09..285ad5163e 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -123,13 +123,7 @@ export class BasicCompactService extends CompactService { let result: CompactionResult | null = null for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) { - const surface = meter.measureSurface(agent.session) - if (surface.logRevision !== measurement.logRevision) { - throw new Error( - `compaction: pressure revision ${measurement.logRevision} does not match surface revision ${surface.logRevision}`, - ) - } - const range = selectCompactableRange(agent.session, surface, this.config.retainTokens) + const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens) if (range === null) { /* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */ if (result === null) return null diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index 83ed04a3eb..60bec61048 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -10,7 +10,7 @@ import { toolPairingBalancedBefore, } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import type { TokenMeterService, TokenSurfaceMeasurement } from '@deepseek-ai/dsh-token-meter' +import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import { frameSummary } from './summarizer.ts' @@ -25,16 +25,16 @@ interface RegionDependencies { * Resolve the next head-anchored range while retaining a priced recent tail * and never splitting an assistant tool-call/result pair. * @param session - session supplying authoritative current surface positions. - * @param pricedSurface - same-revision surface measurement from the conversation meter. + * @param measurement - unified pressure and surface measurement from the conversation meter. * @param retainTokens - minimum recent tail budget retained verbatim. * @returns the inclusive positional seq range to compact, or `null`. */ export function selectCompactableRange( session: Session, - pricedSurface: TokenSurfaceMeasurement, + measurement: TokenMeasurement, retainTokens: number, ): { start: number; end: number } | null { - const pricedNodes = pricedSurface.nodes + const pricedNodes = measurement.nodes if (pricedNodes.length === 0) return null const surfaceNodes = session.surface.nodes @@ -115,8 +115,8 @@ export async function compactSurfaceRegion( try { // Capture after the lock event so any later durable append, including a // log-only one, invalidates the async selection before replacement. - const lockedSurface = dependencies.meter.measureSurface(session) - const selected = lockedSurface.nodes.slice(startIdx, endIdx + 1) + const lockedMeasurement = dependencies.meter.measure(session) + const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1) if (selected.length !== shadowedSeqs.length || selected.some((node, index) => node.seq !== shadowedSeqs[index])) { throw new Error('compaction: selected surface changed before summarization began') @@ -125,8 +125,8 @@ export async function compactSurfaceRegion( const text = renderTranscript(session.events, shadowedSeqs) const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal) - const currentSurface = dependencies.meter.measureSurface(session) - if (currentSurface.logRevision !== lockedSurface.logRevision) { + const currentMeasurement = dependencies.meter.measure(session) + if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) { throw new Error('compaction: session log changed during summarization') } const framedSummary = frameSummary(summary) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index ffc60ef024..486955b187 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -251,17 +251,15 @@ describe('pressure measurement and retention', () => { expect(await compactIfNeeded(compact, retained, MODEL, 'x'.repeat(100_000))).toBeNull() }) - it('detects scalar/surface revision disagreement', async () => { + it('uses one unified measurement for each pressure-and-retention decision', async () => { const ctx = createContext() - const meter = ctx.tokenMeter - const original = meter.measureSurface.bind(meter) - vi.spyOn(meter, 'measureSurface').mockImplementation((session) => { - const measurement = original(session) - return { ...measurement, logRevision: measurement.logRevision - 1 } - }) const compact = service(compactConfig, ctx) + const measure = vi.spyOn(ctx.tokenMeter, 'measure') + const stop = new Error('stop after first decision') + vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop) - await expect(compactIfNeeded(compact, conversation(4))).rejects.toThrow(/revision/) + await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop) + expect(measure).toHaveBeenCalledTimes(1) }) it('bounds retries when a shrinking checkpoint remains above threshold', async () => { @@ -303,7 +301,7 @@ describe('pressure measurement and retention', () => { it('rejects a priced surface that is not the current positional surface', () => { const ctx = createContext() const session = conversation(2) - const priced = ctx.tokenMeter.measureSurface(session) + const priced = ctx.tokenMeter.measure(session) expect(() => selectCompactableRange(session, { ...priced, nodes: priced.nodes.slice(1), @@ -331,7 +329,7 @@ describe('pressure measurement and retention', () => { }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) - const priced = ctx.tokenMeter.measureSurface(session) + const priced = ctx.tokenMeter.measure(session) expect(selectCompactableRange(session, priced, 1)).toBeNull() }) }) @@ -474,8 +472,8 @@ describe('compaction region transaction', () => { it('rejects a meter snapshot that changed before summarization began', async () => { const ctx = createContext() const meter = ctx.tokenMeter - const original = meter.measureSurface.bind(meter) - vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => { + const original = meter.measure.bind(meter) + vi.spyOn(meter, 'measure').mockImplementationOnce((session) => { const measurement = original(session) return { ...measurement, nodes: measurement.nodes.slice(1) } }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6edd2b809e..07ee50b95f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -227,7 +227,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Replay owner for one service-wide estimator and isolated per-session folds.', methods: [ 'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement', - 'measureSurface(session: Session): TokenSurfaceMeasurement', 'estimateMessage(message: Message): number', ], }, @@ -992,16 +991,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TokenMeasurement', - declaration: 'export interface TokenMeasurement {\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n}', + declaration: 'export interface TokenMeasurement {\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n readonly surfaceTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}', }, { name: 'TokenMeasurementBaseline', declaration: 'export type TokenMeasurementBaseline = {\n readonly kind: \'none\';\n readonly tokens: 0;\n} | {\n readonly kind: \'estimated\';\n readonly tokens: number;\n} | {\n readonly kind: \'usage\';\n readonly tokens: number;\n readonly usage: Readonly;\n};', }, - { - name: 'TokenSurfaceMeasurement', - declaration: 'export interface TokenSurfaceMeasurement {\n readonly logRevision: number;\n readonly totalTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}', - }, { name: 'TokenSurfaceNode', declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n}', diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 3beaff1506..4b2a03833e 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -12,13 +12,12 @@ The estimator intentionally uses one fixed heuristic: four characters per token ## Measurement contract -`ctx.tokenMeter` directly exposes three operations: +`ctx.tokenMeter` directly exposes two operations: -- `measure(session, requestHeader?)` returns scalar request pressure at one consumed-log revision. -- `measureSurface(session)` returns current surface nodes and their per-node prices at the same kind of revision. +- `measure(session, requestHeader?)` returns request pressure and the current priced surface at one consumed-log revision. - `estimateMessage(message)` prices one message with the fixed heuristic. -Measurements are detached and deeply immutable. A caller that needs a consistent scalar/surface decision compares their `logRevision` values instead of copying the full history on every read. +`measure()` synchronizes once and returns one detached, deeply immutable snapshot. `totalTokens` is request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override affects pressure fields only; the surface fields still describe the current session. Every call clones the positional nodes, so measurement is O(surface). The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements. @@ -46,5 +45,6 @@ Indirectly, through consumers such as `dsh-compact-basic`; the service itself ad ## Known Limitations and Deferred Work - **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer. +- **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks. - **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, model, or call-config changes deliberately fall back to full heuristic estimation. - **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream. diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index aa88831033..3c446f4778 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -14,7 +14,6 @@ import type { TokenMeasurement, TokenMeasurementBaseline, TokenMeterConfig, - TokenSurfaceMeasurement, TokenSurfaceNode, } from './types.ts' @@ -126,15 +125,19 @@ export class TokenMeterService extends Service { } /** - * Measure current request pressure through the session's durable tail. + * Measure current request pressure and surface through the durable tail. * * Provider usage is reused only when the latest successful call's canonical * request envelope matches `requestHeader`; otherwise the complete envelope * and surface are heuristically repriced. * + * `requestHeader` affects request pressure only; surface fields always + * describe the current session surface. Every call clones those positional + * nodes, so measurement is O(surface). + * * @param session - session to replay through its current durable tail. * @param requestHeader - optional effective request envelope replacing the latest logged header. - * @returns a detached deeply immutable pressure measurement. + * @returns a detached deeply immutable pressure and surface measurement. */ measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement { const state = this._sync(session) @@ -164,19 +167,7 @@ export class TokenMeterService extends Service { baseline, surfaceDeltaTokens, totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens), - })) - } - - /** - * Price the current surface for retention and replacement decisions. - * @param session - session to replay through its current durable tail. - * @returns a detached deeply immutable positional surface measurement. - */ - measureSurface(session: Session): TokenSurfaceMeasurement { - const state = this._sync(session) - return deepFreeze(structuredClone({ - logRevision: state.consumedEvents, - totalTokens: state.surfaceTokens, + surfaceTokens: state.surfaceTokens, nodes: state.surface, })) } diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index 1f2c2f68f2..7fb35af997 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -18,7 +18,7 @@ export type TokenMeasurementBaseline = | { readonly kind: 'estimated'; readonly tokens: number } | { readonly kind: 'usage'; readonly tokens: number; readonly usage: Readonly } -/** Detached immutable scalar pressure at one consumed session-log revision. */ +/** Detached immutable request-pressure and surface snapshot at one consumed log revision. */ export interface TokenMeasurement { /** Number of durable events consumed; equal to the next unread event seq. */ readonly logRevision: number @@ -28,6 +28,10 @@ export interface TokenMeasurement { readonly surfaceDeltaTokens: number /** Non-negative current request-and-response pressure. */ readonly totalTokens: number + /** Total heuristic tokens across the current surface. */ + readonly surfaceTokens: number + /** Current surface nodes in positional head-to-tail order. */ + readonly nodes: readonly TokenSurfaceNode[] } /** One token-priced node in the current ordered session surface. */ @@ -37,13 +41,3 @@ export interface TokenSurfaceNode { /** Heuristic tokens for the exact message projected by this node. */ readonly tokens: number } - -/** Detached immutable priced surface at one consumed session-log revision. */ -export interface TokenSurfaceMeasurement { - /** Number of durable events consumed; equal to the next unread event seq. */ - readonly logRevision: number - /** Total heuristic tokens across the current surface. */ - readonly totalTokens: number - /** Current surface nodes in positional head-to-tail order. */ - readonly nodes: readonly TokenSurfaceNode[] -} diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 648eef01f5..18f78afad2 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -5,7 +5,7 @@ import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' import type { EpochHeader } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' +import type { TokenMeasurement, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' function header(model: string, extras: Omit = {}): EpochHeader { return canonicalHeader({ config: { model }, ...extras }) @@ -71,6 +71,11 @@ function meter(config: TokenMeterConfig = {}): TokenMeterService { return new TokenMeterService(new Context(), config) } +function expectSurfaceTotal(measurement: TokenMeasurement): void { + expect(measurement.nodes.reduce((total, node) => total + node.tokens, 0)) + .toBe(measurement.surfaceTokens) +} + describe('TokenMeterService configuration and registration', () => { it('provides one zero-config context window', () => { const service = meter() @@ -135,36 +140,48 @@ describe('TokenMeterService pricing', () => { baseline: { kind: 'none', tokens: 0 }, surfaceDeltaTokens: 0, totalTokens: 0, + surfaceTokens: 0, + nodes: [], }) expect(Object.isFrozen(result)).toBe(true) expect(Object.isFrozen(result.baseline)).toBe(true) + expect(Object.isFrozen(result.nodes)).toBe(true) + expectSurfaceTotal(result) expect(() => { ;(result as { totalTokens: number }).totalTokens = 1 }).toThrow(TypeError) }) - it('keeps earlier scalar and surface snapshots detached from later replay', () => { + it('keeps an earlier unified snapshot detached from later replay', () => { const service = meter() const session = new Session(SessionId('detached')) session.append('user/message', { content: [{ type: 'text', text: 'first' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - const scalar = service.measure(session) - const surface = service.measureSurface(session) - const scalarCopy = structuredClone(scalar) - const surfaceCopy = structuredClone(surface) + const snapshot = service.measure(session) + const snapshotCopy = structuredClone(snapshot) + expect(Object.isFrozen(snapshot.nodes)).toBe(true) + expect(Object.isFrozen(snapshot.nodes[0])).toBe(true) + expectSurfaceTotal(snapshot) + expect(() => { + ;(snapshot.nodes as Array<{ seq: number; tokens: number }>).push({ seq: 99, tokens: 1 }) + }).toThrow(TypeError) + expect(() => { + ;(snapshot.nodes[0] as { seq: number; tokens: number }).tokens = 1 + }).toThrow(TypeError) session.append('user/message', { content: [{ type: 'text', text: 'second' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - expect(service.measure(session).logRevision).toBe(2) - expect(service.measureSurface(session).nodes).toHaveLength(2) - expect(scalar).toEqual(scalarCopy) - expect(surface).toEqual(surfaceCopy) - expect(scalar.logRevision).toBe(1) - expect(surface.nodes).toHaveLength(1) + const advanced = service.measure(session) + expect(advanced.logRevision).toBe(2) + expect(advanced.nodes).toHaveLength(2) + expectSurfaceTotal(advanced) + expect(snapshot).toEqual(snapshotCopy) + expect(snapshot.logRevision).toBe(1) + expect(snapshot.nodes).toHaveLength(1) }) it('prices header, prefix, tools, and surface when no reusable usage exists', () => { @@ -181,8 +198,27 @@ describe('TokenMeterService pricing', () => { })) const result = service.measure(session) expect(result.baseline.kind).toBe('estimated') - expect(result.totalTokens).toBeGreaterThan(service.measureSurface(session).totalTokens) + expect(result.totalTokens).toBeGreaterThan(result.surfaceTokens) expect(result.logRevision).toBe(session.events.length) + expectSurfaceTotal(result) + }) + + it('keeps request-header overrides out of the returned surface', () => { + const service = meter() + const session = new Session(SessionId('override-surface')) + session.append('user/message', { + content: [{ type: 'text', text: 'question' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + + const logged = service.measure(session) + const overridden = service.measure(session, header('another-model', { + system: 'large override '.repeat(100), + })) + expect(overridden.totalTokens).toBeGreaterThan(logged.totalTokens) + expect(overridden.surfaceTokens).toBe(logged.surfaceTokens) + expect(overridden.nodes).toEqual(logged.nodes) + expectSurfaceTotal(overridden) }) }) @@ -320,27 +356,27 @@ describe('replay anchors and surface folds', () => { source: { kind: 'user' }, }, { surfaceOp: 'append' }) const seeded = new Session(SessionId('surface-seeded'), original.events) - const before = service.measureSurface(seeded) - const beforeScalar = service.measure(seeded) + const before = service.measure(seeded) expect(before.nodes).toHaveLength(2) - expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0) + expect(before.surfaceDeltaTokens).toBeGreaterThan(0) + expectSurfaceTotal(before) const first = seeded.surface.nodes[0]!.seq seeded.append('user/message', { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] }) - const after = service.measureSurface(seeded) - const afterScalar = service.measure(seeded) + const after = service.measure(seeded) expect(after.nodes).toHaveLength(2) expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1) expect(after.logRevision).toBe(seeded.events.length) expect(Object.isFrozen(after.nodes)).toBe(true) expect(Object.isFrozen(after.nodes[0])).toBe(true) - expect(afterScalar.surfaceDeltaTokens).toBeLessThan(0) + expect(after.surfaceDeltaTokens).toBeLessThan(0) + expectSurfaceTotal(after) expect(before.nodes).toHaveLength(2) expect(before.logRevision).toBe(original.events.length) - expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0) + expect(before.surfaceDeltaTokens).toBeGreaterThan(0) }) it('prices an empty assistant surface anchor as zero', () => { @@ -350,10 +386,11 @@ describe('replay anchors and surface folds', () => { durableText: '', provenance: 'empty', }) - const surface = meter().measureSurface(session) + const measurement = meter().measure(session) const assistant = session.events.find(event => event.type === 'assistant/message')! - expect(surface.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }]) - expect(surface.totalTokens).toBe(0) + expect(measurement.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }]) + expect(measurement.surfaceTokens).toBe(0) + expectSurfaceTotal(measurement) }) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e8cb4b8b8b..4a6fd2b88e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -32,7 +32,6 @@ { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" }, { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceNode", "source": "packages/llm/token-meter/src/types.ts" }, - { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceMeasurement", "source": "packages/llm/token-meter/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, From 06e0450b8d8aae6f107818f4a29667c641af44c9 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 15:26:30 +0800 Subject: [PATCH 21/27] fix(token-meter): keep pressure anchors conservative --- docs/core-data-structures/token-meter.md | 2 +- packages/llm/token-meter/README.md | 2 +- packages/llm/token-meter/src/index.ts | 20 ++++++++----- .../llm/token-meter/tests/token-meter.spec.ts | 30 +++++++++++++++++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/docs/core-data-structures/token-meter.md b/docs/core-data-structures/token-meter.md index a6e70f56f6..e082ea08a9 100644 --- a/docs/core-data-structures/token-meter.md +++ b/docs/core-data-structures/token-meter.md @@ -23,7 +23,7 @@ interface TokenMeasurement { } ``` -`baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope. `estimated` means the service priced the complete envelope and surface with its fixed heuristic. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor. `totalTokens` remains request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of the node prices. +`baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope and its total is no lower than that call's full heuristic anchor. `estimated` means no reusable conservative usage anchor exists, so the service priced the complete envelope and surface with its fixed heuristic. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor. `totalTokens` remains request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of the node prices. ## `TokenSurfaceNode` diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 4b2a03833e..d879112897 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -19,7 +19,7 @@ The estimator intentionally uses one fixed heuristic: four characters per token `measure()` synchronizes once and returns one detached, deeply immutable snapshot. `totalTokens` is request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override affects pressure fields only; the surface fields still describe the current session. Every call clones the positional nodes, so measurement is O(surface). -The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements. +The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full heuristic anchor; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements. Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output. diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 3c446f4778..0a3bf4180a 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -128,8 +128,9 @@ export class TokenMeterService extends Service { * Measure current request pressure and surface through the durable tail. * * Provider usage is reused only when the latest successful call's canonical - * request envelope matches `requestHeader`; otherwise the complete envelope - * and surface are heuristically repriced. + * request envelope matches `requestHeader` and its total is no lower than + * that call's full heuristic anchor; otherwise the complete envelope and + * surface are heuristically repriced. * * `requestHeader` affects request pressure only; surface fields always * describe the current session surface. Every call clones those positional @@ -266,14 +267,17 @@ export class TokenMeterService extends Service { event, eventTokens, ) + const anchorSurfaceTokens = stepStart.surfaceTokens + providerAssistantTokens + const providerTokens = usageTokens(event.data.usage) + const estimatedAnchorTokens = this._estimateHeader(nextHeader) + anchorSurfaceTokens nextAnchor = { header: nextHeader, - surfaceTokens: stepStart.surfaceTokens + providerAssistantTokens, - baseline: { - kind: 'usage', - tokens: usageTokens(event.data.usage), - usage: event.data.usage, - }, + surfaceTokens: anchorSurfaceTokens, + // Signed heuristic deltas remain conservative only from an anchor + // that is at least as large as the matching full heuristic price. + baseline: providerTokens >= estimatedAnchorTokens + ? { kind: 'usage', tokens: providerTokens, usage: event.data.usage } + : { kind: 'estimated', tokens: estimatedAnchorTokens }, } } else { const anchorSurfaceTokens = stepStart.surfaceTokens + eventTokens diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 18f78afad2..f5d6afc4b3 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -252,6 +252,36 @@ describe('replay anchors and surface folds', () => { }).toThrow(TypeError) }) + it('selects a heuristic anchor when provider usage would undercut its scale', () => { + const service = meter() + const session = new Session(SessionId('low-usage-anchor')) + const system = 'system context' + const requestHeader = header('deepseek-v4-flash', { system }) + appendSuccessfulCall(session, requestHeader, { + providerText: 'abcd'.repeat(512), + usage: { inputTokens: 20, outputTokens: 7 }, + }) + + const anchored = service.measure(session) + expect(anchored.baseline.kind).toBe('estimated') + const assistant = anchored.nodes[0]!.seq + session.append('user/message', { + content: [{ type: 'text', text: 'short' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { + surfaceOp: { op: 'replace', start: assistant, end: assistant }, + sourceEventSeqs: [assistant], + }) + + const shrunken = service.measure(session) + expect(27 + shrunken.surfaceDeltaTokens).toBeLessThan(0) + expect(shrunken.totalTokens).toBeGreaterThan(0) + expect(shrunken.totalTokens).toBe(service.measure( + session, + header('different-model', { system }), + ).totalTokens) + }) + it('uses an estimated anchor when provider usage is absent', () => { const service = meter() const session = new Session(SessionId('missing-usage')) From 35ff333e51b351265eba93281e1b263d894d1797 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:44:11 +0800 Subject: [PATCH 22/27] docs: move website context window to token meter --- website/zh-CN/guide/config.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md index 3b98c1f286..07943288b8 100644 --- a/website/zh-CN/guide/config.md +++ b/website/zh-CN/guide/config.md @@ -84,14 +84,18 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 You are coding-agent, a coding assistant powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. +# Token 计量:统一定义模型能看到的 token 上限 +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + config: + contextWindow: 128000 + # 自动压缩:对话太长时自动总结旧内容,腾出上下文空间 -# contextWindow 是模型能看到的 token 上限 # thresholdRatio 超过这个比例就触发压缩 # compactionRetries 是压缩后仍超标时的额外重试次数 - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: - contextWindow: 128000 thresholdRatio: 0.8 retainTokens: 20480 maxTokens: 8192 @@ -214,8 +218,6 @@ config: - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' disabled: true - config: - contextWindow: 128000 ``` ## 各插件配置参考 From 5ed531acbaf39d9dae9991ed48d3a775fa11c841 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:42:35 +0800 Subject: [PATCH 23/27] ci(examples): run examples / testing from built lib/js --- .github/workflows/e2e.yml | 10 +- AGENTS.md | 2 +- docs/rfc/INDEX.md | 1 + ...7-run-ci-examples-from-built-lib.i18n.yaml | 6 + ...26-07-17-run-ci-examples-from-built-lib.md | 42 ++++++ ...07-17-run-ci-examples-from-built-lib.zh.md | 42 ++++++ docs/testing.md | 7 +- examples/AGENTS.md | 4 +- examples/acp-agent/tests/acp.e2e.ts | 44 +++--- examples/acp-agent/tests/escalation.e2e.ts | 26 ++-- examples/acp-agent/tests/hooks.e2e.ts | 14 +- .../fixtures/context/time-context}/cordis.yml | 2 +- examples/package.json | 45 ++++++ knip.json | 18 +-- packages/context/time-context/package.json | 1 + .../time-context/tests/time-context.e2e.ts | 40 ++--- packages/context/time-context/tsconfig.json | 5 +- .../mcp/mcp-client/tests/fixture-server.ts | 2 +- .../mcp/mcp-client/tests/mcp-client.e2e.ts | 14 +- packages/subagent/subagent-acp/package.json | 1 + .../subagent-acp/tests/mock-acp-server.ts | 4 +- .../subagent-acp/tests/subagent-acp.e2e.ts | 40 ++--- .../subagent-acp/tests/subagent-acp.spec.ts | 32 ++-- packages/subagent/subagent-acp/tsconfig.json | 3 + packages/support/acp-snapshot/package.json | 2 +- packages/support/acp-snapshot/src/harness.ts | 56 ++++--- packages/support/acp-snapshot/tsconfig.json | 4 +- packages/support/loader-smoke/README.md | 8 +- packages/support/loader-smoke/src/index.ts | 139 +++++++++++++++--- .../loader-smoke/tests/example-launch.spec.ts | 96 ++++++++++++ .../loader-smoke/tests/loader-smoke.spec.ts | 1 + pnpm-lock.yaml | 123 +++++++++++++++- pnpm-workspace.yaml | 7 + scripts/doc-budgets.manifest.json | 4 +- scripts/run-gates.ts | 17 ++- 35 files changed, 679 insertions(+), 183 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md create mode 100644 docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md rename {packages/context/time-context/tests/fixtures => examples/echo-agent/tests/fixtures/context/time-context}/cordis.yml (88%) create mode 100644 examples/package.json create mode 100644 packages/support/loader-smoke/tests/example-launch.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a2015696f4..dd3965f5cd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -110,9 +110,14 @@ jobs: fi echo "DEEPSEEK_API_KEY present." + # The e2e suites boot the example bins in `lib` mode (DSH_EXAMPLE_MODE=lib): + # the built artifact under plain Node, resolving plugins through real package + # exports — the shape a real consumer runs. That requires a prior build. + - name: Build (lib for the e2e example bins) + run: pnpm run build + # Real-API end-to-end tests only. The keyless gates (lint/typecheck/ - # coverage/snapshot/build/etc.) already run in ci.yml on every push/PR; - # no need to repeat them or build first (tests run unbuilt via tsx). + # coverage/snapshot/etc.) already run in ci.yml on every push/PR. # DEEPSEEK_BASE_URL is pinned to the external API; the secret is scoped to # this step (and preflight) only — never exposed to checkout/setup/install. - name: E2E tests (real DeepSeek API) @@ -120,4 +125,5 @@ jobs: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} DEEPSEEK_BASE_URL: https://api.deepseek.com DSH_E2E_MAX_WORKERS: 14 + DSH_EXAMPLE_MODE: lib run: pnpm run test:e2e diff --git a/AGENTS.md b/AGENTS.md index 21e9877e05..e551a7de10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only. +- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)). - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 91e67e8730..ab39d4f314 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -191,6 +191,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [A gated Known-Limitations section in every package README](implemented/process/2026-07-10-readme-known-limitations-gate.md) | 2026-07-10 | | [Package Model Experience contract](implemented/process/2026-07-12-package-model-experience-contract.md) | 2026-07-12 | | [TypeScript Program-backed semantic gates](implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) | 2026-07-14 | +| [Run CI examples from built lib](implemented/process/2026-07-17-run-ci-examples-from-built-lib.md) | 2026-07-17 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml new file mode 100644 index 0000000000..9424f2aa24 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-17-run-ci-examples-from-built-lib.md: aae88ee965b4e2211f3a53aeb9c0ee4944d95c2a +2026-07-17-run-ci-examples-from-built-lib.zh.md: 0cd3822a71b8f7399be93beaac74b2177fcbe7ca diff --git a/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md new file mode 100644 index 0000000000..aae88ee965 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md @@ -0,0 +1,42 @@ +# RFC: Run CI examples from built lib + +Status: implemented + +English | [中文](2026-07-17-run-ci-examples-from-built-lib.zh.md) + +## Problem + +CI boots examples and Cordis-backed test projects through `node --import tsx` and the root tsconfig `paths` map. This adds TypeScript transformation cost and changes package resolution: imports resolve to workspace source instead of following package `exports` into built `lib/`. + +These runs therefore do not test the same code or resolution behavior as an installed consumer. A package can pass CI while its built export graph is incomplete or resolves differently. + +## Decision + +Execution has two modes. `src` is the default local-development mode and uses tsx; `lib` is the strict CI mode and starts built bins with plain Node, without tsx or tsconfig path mapping. + +- CI subprocesses that boot an example or a checked-in `cordis.yml` use `lib` mode. +- TypeScript fixtures that only implement an ACP or MCP peer and do not load Cordis run directly with Node. An explicit source-path regression may remain in `src` mode. + +### Resolution topology + +Every test Cordis config must resolve its bare modules by walking upward from the config directory. + +- `examples/` is one pnpm workspace member and provides the shared `examples/node_modules` resolution root. +- Every checked-in test Cordis config, including snapshot configs and package-owned fixtures, lives under its corresponding `examples//` tree. A config owned by `packages///` maps to `examples//tests/fixtures///cordis.yml`; the test driver and assertions remain package-local. +- Every package named by an example Cordis config is declared in both `examples/package.json` for `lib` resolution and the root `tsconfig.json` references for `src` mode. + +### Launch policy + +The shared Loader test harness selects `src` or `lib` from `DSH_EXAMPLE_MODE`. CI builds first and selects `lib`; an unset mode keeps the fast local source loop. + +## Alternatives considered + +- **Keep CI on tsx** — rejected because it preserves transformation overhead and source-only resolution behavior. +- **Use lib everywhere** — rejected because local development would require a build before every run. Dual mode keeps that cost out of the development loop. +- **Build a private `node_modules` tree per test** — rejected because it duplicates consumer scaffolding. The `examples/` workspace root gives every Cordis config one real and declared resolution path. + +## Consequences + +- CI validates built package exports without tsx changing module resolution; local development retains the no-build source loop. +- CI must build before these tests, and manual `lib` runs can observe stale local artifacts. +- Cordis config dependencies are not visible to normal TypeScript import analysis, so `examples/package.json` and the root tsconfig references must stay synchronized with the configs. diff --git a/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md new file mode 100644 index 0000000000..0cd3822a71 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md @@ -0,0 +1,42 @@ +# RFC: 在 CI 中从构建后的 lib 运行示例 + +Status: implemented + +[English](2026-07-17-run-ci-examples-from-built-lib.md) | 中文 + +## 问题 + +CI 通过 `node --import tsx` 和根 tsconfig 的 `paths` 映射启动示例与加载 Cordis 配置的测试项目。这种方式既增加了 TypeScript 转换开销,也改变了包解析行为:import 会解析到 workspace 源码,而不是经包的 `exports` 进入构建后的 `lib/`。 + +因此,这些测试没有覆盖已安装消费方实际运行的代码和解析路径。即使包的构建导出图不完整或解析结果不同,CI 仍可能通过。 + +## 决策 + +执行机制包含两种模式。`src` 是本地开发的默认模式并使用 tsx;`lib` 是严格的 CI 模式,通过 plain Node 启动构建后的 bin,不加载 tsx,也不使用 tsconfig 路径映射。 + +- CI 中启动示例或签入仓库的 `cordis.yml` 的子进程使用 `lib` 模式。 +- 仅实现 ACP 或 MCP 对端、且不加载 Cordis 的 TypeScript fixture(测试前置数据)直接由 Node 运行。只有显式验证源码路径的回归测试可以保留 `src` 模式。 + +### 解析拓扑 + +每个测试 Cordis 配置都必须能从配置文件所在目录向上解析裸模块。 + +- `examples/` 作为一个 pnpm workspace 成员,提供统一的 `examples/node_modules` 解析根目录。 +- 所有签入仓库的测试 Cordis 配置,包括快照配置和包内测试 fixture,都放在对应的 `examples//` 目录树下。归属 `packages///` 的配置映射到 `examples//tests/fixtures///cordis.yml`;测试驱动和断言仍留在包内。 +- 示例 Cordis 配置中引用的每个包都同时登记在 `examples/package.json` 和根 `tsconfig.json` 的 references 中,分别支持 `lib` 与 `src` 解析。 + +### 启动策略 + +共享 Loader 测试 harness 通过 `DSH_EXAMPLE_MODE` 选择 `src` 或 `lib`。CI 先构建再选择 `lib`;未设置模式时保留快速的本地源码开发回路。 + +## 曾考虑的替代方案 + +- **CI 继续使用 tsx**:不予采纳,因为它会保留转换开销和仅适用于源码的解析行为。 +- **所有环境只使用 lib**:不予采纳,因为本地开发每次运行前都必须构建。双模式避免把这项成本带入开发回路。 +- **每个测试单独构造 `node_modules`**:不予采纳,因为它会重复消费方脚手架。以 `examples/` 作为 workspace 根,可让每个 Cordis 配置通过同一条真实且显式声明的路径解析模块。 + +## 后果 + +- CI 可以验证构建后的包导出,不再受 tsx 模块解析影响;本地开发仍保留免构建的源码回路。 +- CI 必须先构建再运行这些测试;手动执行 `lib` 模式时可能读取陈旧的本地产物。 +- 常规 TypeScript import 分析无法识别 Cordis 配置依赖,因此 `examples/package.json`、根 tsconfig references 与配置文件必须保持同步。 diff --git a/docs/testing.md b/docs/testing.md index d4acdc33c4..8d4f0f554f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -26,7 +26,12 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. -- An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). + +## Test subprocess launch modes + +- CI and build-having test lanes run every example or Cordis-config subprocess from built `lib/` through the shared dual-mode launcher. Do not hand-write `--import tsx` for these subprocesses. +- Protocol and operating-system fixtures that do not load Cordis run erasable `.ts` directly with Node, without tsx or the root paths map. +- Only a test whose subject is source-path resolution may select `src`; state that contract in the test. ## When a snapshot test is required diff --git a/examples/AGENTS.md b/examples/AGENTS.md index df330de838..6b820368e4 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Examples -Runnable harness compositions. **Examples are NOT workspaces**: their private `package.json` files are dependency-free stubs, and the cordis Loader boots each `cordis.yml` unbuilt through `tsx` plus the root tsconfig paths. +Runnable harness compositions. `examples/` is one workspace member and the module-resolution root for runnable and test Cordis configs; it is not a build target. [package.json](package.json) declares the packages loaded by those configs, while each leaf's private `package.json` remains metadata only. Extract reusable logic into `packages/`, where per-file coverage and README gates apply. Examples keep only `cordis.yml` wiring, demo artifacts, and e2e/snapshot scenarios; app package bins own boot glue. @@ -13,7 +13,7 @@ Each example has both: Mock-only examples require only the keyless tier; state that exception in the test. -Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke` for isolation, root-tsconfig loading, subprocess lifecycle, diagnostics, EOF, and cleanup; tests supply paths, environment, input, and assertions. +Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke`; tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples//` leaf. Map a package-owned config to `examples//tests/fixtures///cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 23c6f58301..b19bc9bfb1 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -15,6 +15,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' /** * Boots examples/acp-agent as an ACP subprocess. The key-gated prompt leg @@ -25,8 +26,6 @@ import { // The child runs from a temp cwd, so its bin and config path are absolute. const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// Resolve tsx absolutely because the subprocess runs outside the repo. -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The root tsconfig supplies unbuilt workspace `paths`; making it explicit // avoids accidental resolution through stale built output. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -38,24 +37,21 @@ interface Spawned { stderr: string[] } -// TODO(acp-test-harness): this subprocess/client boot glue is duplicated with -// hooks.e2e.ts and partly with dsh-acp-snapshot's harness. Migrate both e2e -// files onto that launcher before the TSX/env/permission-stub details drift. function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { - const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, '--config', configPath], - { - cwd, - env: { - ...env, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_PERMISSION_MODE: 'danger-full-access', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', configPath], + tsconfigPath: repoTsconfig, + env: { + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), }, + }) + const child = spawn( + launch.command, + launch.args, + { cwd, env: { ...env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] child.stderr.setEncoding('utf8') @@ -138,16 +134,20 @@ describe('acp-agent over real stdio (no key required)', () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) // Collect raw stdout bytes directly (bypass the SDK framing) to inspect. // A dummy key boots the adapter; this purity test sends no prompt and makes no model call. - const child = spawn(process.execPath, ['--import', tsxLoader, binScript, '--config', configPath], { - cwd: workdir, + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', configPath], + tsconfigPath: repoTsconfig, env: { - ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', - TSX_TSCONFIG_PATH: repoTsconfig, DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: join(workdir, '.dsh'), DSH_AGENTS_HOME: join(workdir, '.agents'), }, + }) + const child = spawn(launch.command, launch.args, { + cwd: workdir, + env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'], }) const out: string[] = [] diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index e18ecd2f4c..7e1024c23f 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -15,6 +15,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' /** * Exercises the default ACP composition through the real bin and Loader. The @@ -28,9 +29,8 @@ import { const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The subprocess runs from a temp cwd outside the repo; point tsx at the repo -// tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md). +// tsconfig so the unbuilt `paths` map resolves in src mode (see examples/AGENTS.md). const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) // Without a usable bwrap/Seatbelt runner, the strict attempt fails closed with @@ -55,17 +55,19 @@ interface Spawned { /** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ function spawnAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', configPath], + tsconfigPath: repoTsconfig, + // A dummy key lets the deepseek adapter boot keyless (presence-checked at + // apply, used only on a real model call); the with-key tests carry the + // real key, so the fallback is inert there. + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot' }, + }) const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, '--config', configPath], - { - cwd, - // A dummy key lets the deepseek adapter boot keyless (presence-checked at - // apply, used only on a real model call); the with-key tests carry the - // real key, so the fallback is inert there. - env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig }, - stdio: ['pipe', 'pipe', 'pipe'], - }, + launch.command, + launch.args, + { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] child.stderr.setEncoding('utf8') diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index 99cebb907e..68dc2648bf 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -15,6 +15,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' /** * With-key e2e for the Claude hook bridge. The process-level `./hooks.json` is @@ -25,7 +26,6 @@ import { const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) interface Spawned { @@ -36,10 +36,16 @@ interface Spawned { } function spawnAcpAgent(cwd: string): Spawned { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', configPath], + tsconfigPath: repoTsconfig, + env: { DSH_PERMISSION_MODE: 'danger-full-access' }, + }) const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, '--config', configPath], - { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig, DSH_PERMISSION_MODE: 'danger-full-access' }, stdio: ['pipe', 'pipe', 'pipe'] }, + launch.command, + launch.args, + { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] child.stderr.setEncoding('utf8') diff --git a/packages/context/time-context/tests/fixtures/cordis.yml b/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml similarity index 88% rename from packages/context/time-context/tests/fixtures/cordis.yml rename to examples/echo-agent/tests/fixtures/context/time-context/cordis.yml index 480a93c39b..9b59e2ded9 100644 --- a/packages/context/time-context/tests/fixtures/cordis.yml +++ b/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml @@ -1,6 +1,6 @@ # Test-only composition: keep time-context opt-in while exercising its real Loader/app path. - id: mock-llm - name: '../../../../../examples/echo-agent/src/mock-llm.ts' + name: '../../../../src/mock-llm.ts' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000000..a6b6e97bc0 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,45 @@ +{ + "name": "dsh-examples", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "dependencies": { + "@cordisjs/plugin-hmr": "workspace:*", + "@cordisjs/plugin-include": "workspace:*", + "@deepseek-ai/dsh-acp-demo": "workspace:*", + "@deepseek-ai/dsh-bash-local": "workspace:*", + "@deepseek-ai/dsh-bash-sandbox": "workspace:*", + "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", + "@deepseek-ai/dsh-compact-basic": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-fs-policy": "workspace:*", + "@deepseek-ai/dsh-hooks-claude": "workspace:*", + "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-permission": "workspace:*", + "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", + "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-spill-local": "workspace:*", + "@deepseek-ai/dsh-spill-policy": "workspace:*", + "@deepseek-ai/dsh-stdio-demo": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-subagent-fork": "workspace:*", + "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-time-context": "workspace:*", + "@deepseek-ai/dsh-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tool-cordis": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-web": "workspace:*", + "@deepseek-ai/dsh-web-fetch-local": "workspace:*", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:*" + } +} diff --git a/knip.json b/knip.json index 9c644cebb7..ad83bbbd2e 100644 --- a/knip.json +++ b/knip.json @@ -5,15 +5,16 @@ "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime", "website"], "workspaces": { ".": { + "project": ["scripts/**/*.ts"] + }, + "examples": { "entry": [ - "examples/echo-agent/src/*.ts", - "examples/echo-agent/tests/**/*.e2e.ts", - "examples/coding-agent/tests/**/*.e2e.ts", - "examples/cordis-agent/tests/**/*.e2e.ts", - "examples/acp-agent/tests/**/*.e2e.ts", - "examples/*/tests/**/*.snapshot.ts" + "echo-agent/src/*.ts", + "*/tests/**/*.e2e.ts", + "*/tests/**/*.snapshot.ts" ], - "project": ["scripts/**/*.ts", "examples/**/*.ts"] + "project": ["**/*.ts"], + "ignoreDependencies": ["@deepseek-ai/.+", "@cordisjs/.+"] }, "packages/*/*": { "entry": ["tests/**/*.spec.ts"], @@ -52,8 +53,7 @@ }, "packages/support/acp-snapshot": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/support/loader-smoke": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"], diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 8900eaf900..c0d69a75cb 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index 5c9b4e8e62..f14981ea36 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -4,12 +4,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +// Keep the Loader config under examples so both modes exercise the same deployable +// topology: local fixture source plus bare plugins owned by the examples workspace. const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/context/time-context/cordis.yml', + import.meta.url, +)) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const PROCESS_TIMEOUT_MS = 30_000 const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:' @@ -39,21 +44,22 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-')) const cwd = workdir return new Promise((resolve, reject) => { - const proc = spawn( - process.execPath, - ['--expose-internals', '--import', tsxLoader, binScript, configPath], - { - cwd, - env: { - ...process.env, - TZ: 'Asia/Shanghai', - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + TZ: 'Asia/Shanghai', + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), }, - ) + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) child = proc let stdout = '' let stderr = '' diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index f8e14d8aa2..7815242b55 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, - { "path": "../../core/agent" } + { "path": "../../core/agent" }, + { "path": "../../core/system-prompt" }, + { "path": "../../core/agent" }, + { "path": "../../support/loader-smoke" } ] } diff --git a/packages/mcp/mcp-client/tests/fixture-server.ts b/packages/mcp/mcp-client/tests/fixture-server.ts index d127412736..8491b96634 100644 --- a/packages/mcp/mcp-client/tests/fixture-server.ts +++ b/packages/mcp/mcp-client/tests/fixture-server.ts @@ -2,7 +2,7 @@ * Minimal MCP server over stdio for e2e testing of the dsh-mcp-client plugin. * Registers controlled tools with predictable behavior for asserting edge cases. * - * Run: node --import tsx fixture-server.ts + * Run: node fixture-server.ts */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index 686d51acea..dc783b3edf 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -26,9 +26,7 @@ import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) // Resolve package-local .bin for pnpm-hoisted MCP server binaries. const packageDir = fileURLToPath(new URL('..', import.meta.url)) @@ -86,8 +84,8 @@ describe('fixture server — controlled scenarios', () => { transport: 'stdio', serverName: 'fixture', command: process.execPath, - args: ['--import', tsxLoader, fixtureServerPath], - env: { TSX_TSCONFIG_PATH: repoTsconfig }, + args: [fixtureServerPath], + env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, } @@ -170,8 +168,8 @@ describe('fixture server — duplicate serverName', () => { transport: 'stdio', serverName: 'dup', command: process.execPath, - args: ['--import', tsxLoader, fixtureServerPath], - env: { TSX_TSCONFIG_PATH: repoTsconfig }, + args: [fixtureServerPath], + env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, } @@ -191,8 +189,8 @@ describe('fixture server — disposal', () => { transport: 'stdio', serverName: 'fixture', command: process.execPath, - args: ['--import', tsxLoader, fixtureServerPath], - env: { TSX_TSCONFIG_PATH: repoTsconfig }, + args: [fixtureServerPath], + env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, }) diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 5093e3df40..ec55845f91 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -35,6 +35,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.5", diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 000f6d49f0..1496f882aa 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -2,8 +2,8 @@ * Minimal no-network ACP child process for keyless backend tests. Environment variables script its * text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a * readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark - * SIGTERM, or trap SIGTERM to require SIGKILL. The specs spawn this non-test module under tsx with - * an explicit tsconfig, mirroring real example boot. + * SIGTERM, or trap SIGTERM to require SIGKILL. The specs run this protocol-only fixture directly + * with Node's type stripping; it imports no harness code or workspace paths. * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server */ diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 0c20b27fa1..688b1c44aa 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import * as acp from '../src/index.ts' /** @@ -17,9 +18,22 @@ import * as acp from '../src/index.ts' // The real acp-agent example: its bin + cordis.yml (the live DeepSeek config). const binScript = fileURLToPath(new URL('../../../examples/acp-demo/src/bin.ts', import.meta.url)) const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +// How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE). +// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is +// forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only. +const childLaunch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', exampleConfig], + tsconfigPath: repoTsconfig, + env: { + ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, + ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, + DSH_PERMISSION_MODE: 'danger-full-access', + }, +}) + /** The ACP backend ignores the parent, but the seam requires one. */ const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent @@ -40,18 +54,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive await ctx.plugin(SubagentService) await ctx.plugin(acp, { providerName: 'acp', - command: process.execPath, - args: ['--import', tsxLoader, binScript, '--config', exampleConfig], + command: childLaunch.command, + args: childLaunch.args, cwd: workdir, permission: 'reject', - // The child harness needs the key to reach the model; forward it - // explicitly (buildChildEnv scrubs ambient creds but keeps these extras). - env: { - ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, - ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_PERMISSION_MODE: 'danger-full-access', - }, + env: childLaunch.env as Record, }) const run = await ctx.subagents.start('acp', { @@ -76,17 +83,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive await ctx.plugin(SubagentService) await ctx.plugin(acp, { providerName: 'acp', - command: process.execPath, - args: ['--import', tsxLoader, binScript, '--config', exampleConfig], + command: childLaunch.command, + args: childLaunch.args, cwd: workdir, // The child needs to act (run bash), so approve its permission prompts. permission: 'allow', - env: { - ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, - ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_PERMISSION_MODE: 'danger-full-access', - }, + env: childLaunch.env as Record, }) const run = await ctx.subagents.start('acp', { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index eed3cfe1ed..bfc8476bae 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -21,8 +21,6 @@ import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DI */ const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) /** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */ const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent @@ -46,11 +44,9 @@ async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'r await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], permission, - // The mock-server scripting vars must reach the child; TSX_TSCONFIG_PATH lets - // tsx resolve @deepseek-ai/* from a child cwd outside the repo. - env: { ...mockEnv, TSX_TSCONFIG_PATH: repoTsconfig }, + env: mockEnv, }) return ctx } @@ -197,10 +193,10 @@ describe('dsh-subagent-acp', () => { try { const spec: AcpRunSpec = { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', - env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready }, // Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must // burn the EOF window, then the SIGTERM window, then SIGKILL — keep each // small so the whole ladder finishes well within the 4000ms bound. @@ -240,7 +236,7 @@ describe('dsh-subagent-acp', () => { try { const spec: AcpRunSpec = { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', // MOCK_HANG so the prompt never resolves on its own — we tear down a live @@ -249,7 +245,7 @@ describe('dsh-subagent-acp', () => { // wider grace. env: { MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, - MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig, + MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', }, disposeEofGraceMs: 2000, disposeGraceMs: 50, @@ -280,12 +276,12 @@ describe('dsh-subagent-acp', () => { try { const spec: AcpRunSpec = { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', env: { MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x', - MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, TSX_TSCONFIG_PATH: repoTsconfig, + MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, }, // Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM. disposeEofGraceMs: 150, @@ -404,9 +400,9 @@ describe('dsh-subagent-acp', () => { await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], permission: 'reject', - env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready }, disposeEofGraceMs: 150, disposeGraceMs: 150, }) @@ -455,10 +451,10 @@ describe('dsh-subagent-acp', () => { request(), { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', - env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig }, + env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, @@ -493,10 +489,10 @@ describe('dsh-subagent-acp', () => { request(), { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', - env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig }, + env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, onError: () => { throw new Error('sink boom') }, diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index e415ace1de..5aa28528ac 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../subagent-subprocess" + }, + { + "path": "../../support/loader-smoke" } ] } diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index d206b08161..eb6b46deaf 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -23,7 +23,7 @@ "license": "BSD-3-Clause", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", - "tsx": "^4.22.4", + "@deepseek-ai/dsh-loader-smoke": "workspace:*", "vitest": "^4.1.8" }, "peerDependencies": { diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index c26cb84299..05d79d3330 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -11,7 +11,6 @@ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, delimiter } from 'node:path' -import { fileURLToPath } from 'node:url' import { Readable, Writable } from 'node:stream' import { ClientSideConnection, @@ -23,12 +22,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' - -// Resolve tsx's ESM loader to an ABSOLUTE path once: the child runs with its -// cwd in a temp dir OUTSIDE the repo, where a bare `--import tsx` would not -// resolve from node_modules. import.meta.resolve gives this package's tsx -// regardless of the child cwd. -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' /** * The agent composition a scenario runs against: which bin to boot and which @@ -37,7 +31,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) * them from its own `import.meta.url`. */ export interface AgentUnderTest { - /** The agent bin entry (e.g. `packages/examples/acp-demo/src/bin.ts`), run unbuilt via tsx. */ + /** The agent bin's SOURCE entry (e.g. `packages/examples/acp-demo/src/bin.ts`); the `lib` bin is derived from it. */ binScript: string /** * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps @@ -47,10 +41,8 @@ export interface AgentUnderTest { configPath: string /** * The repo-root tsconfig whose `paths` map resolves the unbuilt workspace - * imports. Passed to the child as `TSX_TSCONFIG_PATH`: tsx finds a tsconfig - * by searching UP from the child's cwd — a temp dir outside the repo — so - * without the explicit pin the dsh-* imports fail before the bin writes a - * byte. + * imports in `src` mode (passed to the child as `TSX_TSCONFIG_PATH`). Ignored + * in `lib` mode, where the example resolves plugins through real `exports`. */ tsconfigPath: string } @@ -184,25 +176,31 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } - const env: NodeJS.ProcessEnv = { - ...process.env, - TSX_TSCONFIG_PATH: opts.agent.tsconfigPath, - DSH_SNAPSHOT: opts.mode, - DSH_SNAPSHOT_FILE: opts.fixtureFile, - DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, - DSH_SNAPSHOT_SPILL_ROOT: spillRoot, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, - ...opts.childFiles !== undefined && opts.childFiles.length > 0 - ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } - : {}, - } + // Boot the agent in the environment's mode (DSH_EXAMPLE_MODE): `src` runs the + // source bin under tsx with the paths map; `lib` runs the built bin under plain + // Node, resolving plugins through the example's workspace node_modules → lib. + const launch = resolveExampleLaunch({ + srcBin: opts.agent.binScript, + configArgs: ['--config', opts.configPath ?? opts.agent.configPath], + tsconfigPath: opts.agent.tsconfigPath, + env: { + DSH_SNAPSHOT: opts.mode, + DSH_SNAPSHOT_FILE: opts.fixtureFile, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + DSH_SNAPSHOT_SPILL_ROOT: spillRoot, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, + ...opts.childFiles !== undefined && opts.childFiles.length > 0 + ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } + : {}, + }, + }) child = spawn( - process.execPath, - ['--import', tsxLoader, opts.agent.binScript, '--config', opts.configPath ?? opts.agent.configPath], - { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, + launch.command, + launch.args, + { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, ) child.stderr.setEncoding('utf8') diff --git a/packages/support/acp-snapshot/tsconfig.json b/packages/support/acp-snapshot/tsconfig.json index 749cb0208e..9120df0ad1 100644 --- a/packages/support/acp-snapshot/tsconfig.json +++ b/packages/support/acp-snapshot/tsconfig.json @@ -7,5 +7,7 @@ "include": [ "src" ], - "references": [] + "references": [ + { "path": "../loader-smoke" } + ] } diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index ea197b25d0..04f519b4c1 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -1,10 +1,10 @@ # `@deepseek-ai/dsh-loader-smoke` -Shared subprocess harness for keyless example smokes that boot the real stdio-agent bin and a real `cordis.yml` through the Cordis Loader. A test supplies absolute bin/config/tsconfig paths, optional environment overrides, and stdin lines; `runLoaderSmoke` owns the isolated cwd, DSH homes, tsx path resolution, 30-second process deadline, captured diagnostics, forced kill, EOF, and cleanup. +Shared subprocess harness for tests that boot an app and `cordis.yml` through the Cordis Loader. `resolveExampleLaunch` selects local `src` mode (tsx and root tsconfig paths) or CI `lib` mode (plain Node and package exports) from an explicit mode or `DSH_EXAMPLE_MODE`. -Successful runs return stdout and stderr only after a zero exit. Non-zero exits and deadlines reject with both captured streams. `LOADER_SMOKE_TEST_TIMEOUT_MS` leaves Vitest enough room for the process-owned diagnostic timeout to fire first. +`runLoaderSmoke` owns the isolated cwd, DSH homes, stdin, diagnostics, deadline, termination, and cleanup. It returns both streams after a zero exit and rejects with both streams on failure. -This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,cordis-agent}`. +This is support-tier test infrastructure, not product API. ## Model Experience @@ -12,6 +12,6 @@ None, as this test-only harness boots example processes and inspects their strea ## Known Limitations and Deferred Work -- **Only the unbuilt tsx/Loader path is exercised** — built-bin artifacts remain the responsibility of their separate e2e smokes. +- **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`. - **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it. - **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup. diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 72839c6a05..f39c6d678c 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -2,6 +2,14 @@ * Shared subprocess harness for keyless example smokes that boot a real * `cordis.yml` through the stdio-agent bin and Cordis Loader. * + * It also owns the mode-aware launch resolver every example subprocess harness shares + * ({@link resolveExampleLaunch}): booting an example bin from TypeScript source under `tsx` (the + * zero-build dev path, resolving `@deepseek-ai/dsh-*` / `@cordisjs/*` through the tsconfig `paths` + * map) or from built `lib/` under plain Node (resolving bare packages through real `exports`, as an + * installed consumer does, while Node type-strips relative example-local TypeScript plugins). + * Consolidating that spawn glue here retires the copies in the ACP snapshot harness and the example + * e2e drivers (the `TODO(acp-test-harness)`). + * * @module @deepseek-ai/dsh-loader-smoke */ @@ -12,23 +20,116 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 -const TSX_LOADER = fileURLToPath(import.meta.resolve('tsx')) /** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */ export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000 +/** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */ +export type ExampleMode = 'src' | 'lib' + +/** Environment variable selecting the mode; CI and pre-push set it to `lib`, dev leaves it unset (`src`). */ +export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE' + +/** + * Parse an {@link ExampleMode} from a raw string, defaulting to `src` when absent so an unset + * environment reproduces the dev/tsx behavior. Throws on any other value rather than silently + * falling back, so a typo in a gate's env fails loud. + * @param raw - the raw value; defaults to `process.env.DSH_EXAMPLE_MODE`. + * @returns the validated mode. + */ +export function resolveExampleMode(raw: string | undefined = process.env[EXAMPLE_MODE_ENV]): ExampleMode { + switch (raw) { + case undefined: + case '': + case 'src': + return 'src' + case 'lib': + return 'lib' + default: + throw new Error(`${EXAMPLE_MODE_ENV} must be 'src' or 'lib', got ${JSON.stringify(raw)}.`) + } +} + +/** Inputs to {@link resolveExampleLaunch}. */ +export interface ExampleLaunchOptions { + /** Absolute path to the example bin's TypeScript source entry (`/src/bin.ts`); the `lib` bin is derived from it. */ + readonly srcBin: string + /** Arguments passed after the bin — the config, positional (`[configPath]`) or flagged (`['--config', configPath]`). */ + readonly configArgs?: readonly string[] + /** The mode to launch in; defaults to {@link resolveExampleMode} of the environment. */ + readonly mode?: ExampleMode + /** Absolute repo tsconfig whose `paths` map resolves unbuilt workspace imports. Required in `src` mode, ignored in `lib`. */ + readonly tsconfigPath?: string + /** Prepend `--expose-internals` (the Cordis Loader's bare-plugin resolver needs it for some bins); defaults to `false`. */ + readonly exposeInternals?: boolean + /** Extra environment entries the mode-specific ones layer over; the caller then merges the result over `process.env`. */ + readonly env?: NodeJS.ProcessEnv +} + +/** The resolved spawn: `spawn(command, args, { env: { ...process.env, ...env } })`. */ +export interface ExampleLaunch { + /** The executable to spawn — always the current Node binary. */ + readonly command: string + /** Node flags, the resolved bin, then the caller's `configArgs`. */ + readonly args: string[] + /** Mode-specific environment (`TSX_TSCONFIG_PATH` in `src`, nothing added in `lib`) layered over the caller's `env`. */ + readonly env: NodeJS.ProcessEnv +} + +/** Derive the built-lib bin (`/lib/.js`) from a source bin (`/src/.ts`). */ +function toLibBin(srcBin: string): string { + const marker = '/src/' + const cut = srcBin.lastIndexOf(marker) + if (cut === -1) throw new Error(`resolveExampleLaunch: expected a "/src/" segment in bin path ${JSON.stringify(srcBin)}.`) + const tail = srcBin.slice(cut + marker.length).replace(/\.ts$/, '.js') + return `${srcBin.slice(0, cut)}/lib/${tail}` +} + +/** + * Resolve how to spawn an example bin in the selected mode. + * + * `src` yields `node [--expose-internals] --import ` with `TSX_TSCONFIG_PATH` + * set so the tsconfig `paths` map resolves workspace imports to source. `lib` yields + * `node [--expose-internals] ` under plain Node with no tsx and no paths map, so + * bare package plugins resolve through real package `exports` into built `lib/`; relative example-local + * TypeScript plugins remain source files loaded through Node's built-in type stripping. Bare resolution + * requires the config to live below a workspace that declares its `cordis.yml` package dependencies. + * + * @param options - the source bin, config arguments, mode, and environment. + * @returns the command, argument vector, and mode-specific environment to spawn with. + */ +export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaunch { + const mode = options.mode ?? resolveExampleMode() + const configArgs = options.configArgs ?? [] + const flags = options.exposeInternals === true ? ['--expose-internals'] : [] + const env: NodeJS.ProcessEnv = { ...options.env } + + if (mode === 'src') { + if (options.tsconfigPath === undefined) { + throw new Error("resolveExampleLaunch: 'src' mode needs tsconfigPath for the workspace paths map.") + } + const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) + env.TSX_TSCONFIG_PATH = options.tsconfigPath + return { command: process.execPath, args: [...flags, '--import', tsxLoader, options.srcBin, ...configArgs], env } + } + + return { command: process.execPath, args: [...flags, toLibBin(options.srcBin), ...configArgs], env } +} + /** Inputs that vary between real-Loader example smokes. */ export interface LoaderSmokeOptions { /** Human-readable example name used in failure diagnostics. */ readonly label: string /** Prefix for the isolated temporary process cwd. */ readonly tempDirPrefix: string - /** Absolute stdio-agent bin path. */ + /** Absolute stdio-agent bin SOURCE path (`/src/bin.ts`); the `lib` bin is derived from it. */ readonly binScript: string /** Absolute real Loader config path. */ readonly configPath: string - /** Absolute repo tsconfig path used for unbuilt workspace-package resolution. */ + /** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */ readonly tsconfigPath: string + /** Boot from source via tsx (`src`) or built lib via plain Node (`lib`); defaults to the environment's mode. */ + readonly mode?: ExampleMode /** Environment overrides layered over the parent and isolated DSH homes. */ readonly env?: Readonly /** Lines written to stdin before EOF; omitted means immediate EOF. */ @@ -48,30 +149,28 @@ export interface LoaderSmokeResult { /** * Boot one real Loader tree from an isolated cwd, write the requested stdin * script, close stdin, and await a clean exit. The helper owns process kill and - * temp-directory cleanup on every outcome. - * @param options - example paths, environment, stdin, and diagnostic identity. + * temp-directory cleanup on every outcome, and picks src/lib via {@link resolveExampleLaunch}. + * @param options - example paths, mode, environment, stdin, and diagnostic identity. * @returns captured stdout and stderr after a zero exit. */ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix)) const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS + const launch = resolveExampleLaunch({ + srcBin: options.binScript, + configArgs: [options.configPath], + ...options.mode !== undefined ? { mode: options.mode } : {}, + tsconfigPath: options.tsconfigPath, + exposeInternals: true, + env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env }, + }) try { return await new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - ['--expose-internals', '--import', TSX_LOADER, options.binScript, options.configPath], - { - cwd, - env: { - ...process.env, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - ...options.env, - TSX_TSCONFIG_PATH: options.tsconfigPath, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) + const child = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) let stdout = '' let stderr = '' let deferredFailure: Error | undefined diff --git a/packages/support/loader-smoke/tests/example-launch.spec.ts b/packages/support/loader-smoke/tests/example-launch.spec.ts new file mode 100644 index 0000000000..d1172db711 --- /dev/null +++ b/packages/support/loader-smoke/tests/example-launch.spec.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + EXAMPLE_MODE_ENV, + resolveExampleLaunch, + resolveExampleMode, +} from '@deepseek-ai/dsh-loader-smoke' + +const SRC_BIN = '/repo/packages/examples/stdio-demo/src/bin.ts' +const TSCONFIG = '/repo/tsconfig.json' + +const originalMode = process.env[EXAMPLE_MODE_ENV] +afterEach(() => { + if (originalMode === undefined) Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV) + else process.env[EXAMPLE_MODE_ENV] = originalMode +}) + +describe('resolveExampleMode', () => { + it('defaults absent/empty/src to src', () => { + expect(resolveExampleMode(undefined)).toBe('src') + expect(resolveExampleMode('')).toBe('src') + expect(resolveExampleMode('src')).toBe('src') + }) + + it('accepts lib', () => { + expect(resolveExampleMode('lib')).toBe('lib') + }) + + it('throws on any other value', () => { + expect(() => resolveExampleMode('prod')).toThrow(/must be 'src' or 'lib'/) + }) + + it('reads the environment when no argument is given', () => { + process.env[EXAMPLE_MODE_ENV] = 'lib' + expect(resolveExampleMode()).toBe('lib') + Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV) + expect(resolveExampleMode()).toBe('src') + }) +}) + +describe('resolveExampleLaunch', () => { + it('src mode: --import tsx on the source bin with the tsconfig paths env', () => { + const { command, args, env } = resolveExampleLaunch({ + srcBin: SRC_BIN, + configArgs: ['./cordis.yml'], + mode: 'src', + tsconfigPath: TSCONFIG, + }) + expect(command).toBe(process.execPath) + expect(args).toContain('--import') + expect(args).toContain(SRC_BIN) + expect(args[args.length - 1]).toBe('./cordis.yml') + expect(args).not.toContain('--expose-internals') + expect(env.TSX_TSCONFIG_PATH).toBe(TSCONFIG) + }) + + it('src mode: throws without a tsconfig path', () => { + expect(() => resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'src' })).toThrow(/needs tsconfigPath/) + }) + + it('lib mode: plain node on the derived lib bin, no tsx and no paths env', () => { + const { args, env } = resolveExampleLaunch({ + srcBin: SRC_BIN, + configArgs: ['--config', './cordis.yml'], + mode: 'lib', + env: { DSH_HOME: '/tmp/home' }, + }) + expect(args).not.toContain('--import') + expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js') + expect(args.slice(-2)).toEqual(['--config', './cordis.yml']) + expect(env.TSX_TSCONFIG_PATH).toBeUndefined() + expect(env.DSH_HOME).toBe('/tmp/home') + }) + + it('prepends --expose-internals when requested', () => { + const { args } = resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'lib', exposeInternals: true }) + expect(args[0]).toBe('--expose-internals') + }) + + it('lib mode: rewrites only the last /src/ segment', () => { + const { args } = resolveExampleLaunch({ + srcBin: '/repo/src/packages/examples/acp-demo/src/bin.ts', + mode: 'lib', + }) + expect(args).toContain('/repo/src/packages/examples/acp-demo/lib/bin.js') + }) + + it('lib mode: throws when the bin has no /src/ segment', () => { + expect(() => resolveExampleLaunch({ srcBin: '/repo/lib/bin.js', mode: 'lib' })).toThrow(/"\/src\/" segment/) + }) + + it('defaults the mode from the environment', () => { + process.env[EXAMPLE_MODE_ENV] = 'lib' + const { args } = resolveExampleLaunch({ srcBin: SRC_BIN }) + expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js') + }) +}) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 4cc9f878f9..991a1a64af 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -16,6 +16,7 @@ describe('runLoaderSmoke', () => { binScript: fixture('success'), configPath, tsconfigPath, + mode: 'src', env: { LOADER_SMOKE_MARKER: 'present' }, stdinLines: ['one', 'two'], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d6081cd07..39b7638b69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,117 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + examples: + dependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:* + version: link:../vendor/hmr + '@cordisjs/plugin-include': + specifier: workspace:* + version: link:../vendor/include + '@deepseek-ai/dsh-acp-demo': + specifier: workspace:* + version: link:../packages/examples/acp-demo + '@deepseek-ai/dsh-bash-local': + specifier: workspace:* + version: link:../packages/bash/bash-local + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:* + version: link:../packages/bash/bash-sandbox + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:* + version: link:../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:* + version: link:../packages/compact/compact-basic + '@deepseek-ai/dsh-fs-local': + specifier: workspace:* + version: link:../packages/fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:* + version: link:../packages/fs/fs-policy + '@deepseek-ai/dsh-hooks-claude': + specifier: workspace:* + version: link:../packages/hooks/hooks-claude + '@deepseek-ai/dsh-hooks-codex': + specifier: workspace:* + version: link:../packages/hooks/hooks-codex + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../packages/llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:* + version: link:../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-replay': + specifier: workspace:* + version: link:../packages/support/llm-replay + '@deepseek-ai/dsh-permission': + specifier: workspace:* + version: link:../packages/ui/permission + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:* + version: link:../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:* + version: link:../packages/sandbox/sandbox-local + '@deepseek-ai/dsh-spill-local': + specifier: workspace:* + version: link:../packages/spill/spill-local + '@deepseek-ai/dsh-spill-policy': + specifier: workspace:* + version: link:../packages/spill/spill-policy + '@deepseek-ai/dsh-stdio-demo': + specifier: workspace:* + version: link:../packages/examples/stdio-demo + '@deepseek-ai/dsh-subagent': + specifier: workspace:* + version: link:../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:* + version: link:../packages/subagent/subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:* + version: link:../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-time-context': + specifier: workspace:* + version: link:../packages/context/time-context + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:* + version: link:../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:* + version: link:../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:* + version: link:../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:* + version: link:../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:* + version: link:../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:* + version: link:../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:* + version: link:../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../packages/core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:* + version: link:../packages/ui/user-approval + '@deepseek-ai/dsh-web': + specifier: workspace:* + version: link:../packages/web/web + '@deepseek-ai/dsh-web-fetch-local': + specifier: workspace:* + version: link:../packages/web/web-fetch-local + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:* + version: link:../packages/workflow/workflow-workerthread + packages/bash/bash: devDependencies: '@deepseek-ai/dsh-sandbox': @@ -293,6 +404,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1352,6 +1466,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -1532,9 +1649,9 @@ importers: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) - tsx: - specifier: ^4.22.4 - version: 4.22.4 + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:* + version: link:../loader-smoke vitest: specifier: ^4.1.8 version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 07182c9959..8f93814899 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,13 @@ packages: - vendor/* - packages/*/* - website + # The runnable demo leaves join as ONE workspace member: examples/package.json + # declares the union of every leaf's cordis.yml plugins as workspace:*, so a + # plain-node (`:lib`) boot of any leaf (examples//cordis.yml) resolves its + # plugins through real package `exports`→lib by walking up to examples/node_modules. + # Members for DEPENDENCY RESOLUTION only — NOT build targets: tsdown's explicit + # globs (vendor/*, packages/*/*) exclude them. See the example-execute-over-tsx RFC. + - examples # Deploy root of the single-exe build: a pure dependency manifest whose # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index f8a39137aa..3b99ad454a 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -4,8 +4,8 @@ "docs/architecture.md": 1790, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 800, - "examples/AGENTS.md": 200, + "docs/testing.md": 960, + "examples/AGENTS.md": 310, "packages/AGENTS.md": 290, "packages/README.md": 760 } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index fde83ff5ea..378cafed90 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -167,7 +167,8 @@ function gatesForMode(selected: Mode): Gate[] { ] case 'ci-snapshot': return [ - pnpmScript('snapshot', 'test:snapshot'), + pnpmScript('build', 'build'), + snapshotGate(), ] case 'ci-artifacts': return ciArtifactGates() @@ -186,7 +187,7 @@ function gatesForMode(selected: Mode): Gate[] { pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('test', 'test'), pnpmScript('duplication', 'duplication'), - pnpmScript('snapshot', 'test:snapshot'), + snapshotGate(), pnpmScript('build', 'build'), ...hygieneLeafGates({ artifactNeeds: ['build'] }), ...docSyncLeafGates({ @@ -207,7 +208,7 @@ function ciPrimaryGates(): Gate[] { lintGate(), pnpmScript('duplication', 'duplication'), coverageGate(), - pnpmScript('snapshot', 'test:snapshot'), + snapshotGate(), demoSmokeGate({ needs: ['lint'] }), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -284,6 +285,16 @@ function coverageGate(): Gate { }) } +// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node, +// plugins via real exports) — CI and pre-push already build, so they exercise what ships rather +// than the tsx/source path dev uses. It therefore waits on `build`. +function snapshotGate(): Gate { + return pnpmScript('snapshot', 'test:snapshot', { + env: { DSH_EXAMPLE_MODE: 'lib' }, + needs: ['build'], + }) +} + function positiveIntArg(envName: string, flag: string): string[] { const raw = process.env[envName] if (raw === undefined || raw === '') return [] From df21d22e28406b46a7f13941d771f9d93cf55208 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:38:05 +0800 Subject: [PATCH 24/27] fix(examples): enforce config dependency resolution --- examples/package.json | 1 + pnpm-lock.yaml | 3 + scripts/verify-cordis-config.ts | 102 +++++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/examples/package.json b/examples/package.json index a6b6e97bc0..8d77731a05 100644 --- a/examples/package.json +++ b/examples/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-subagent-spawn": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39b7638b69..9810a9a61e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:* version: link:../packages/timeout/timeout-policy + '@deepseek-ai/dsh-token-meter': + specifier: workspace:* + version: link:../packages/llm/token-meter '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/cordis/tool-cordis diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 131bcf0e58..c4075cb256 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -1,18 +1,32 @@ /** - * Reject JavaScript expressions in Cordis Loader entry metadata. + * Validate Cordis Loader entry metadata and example package resolution. * * The Loader interpolates only a plugin entry's `config`; expression objects in * fields such as `disabled` remain truthy data and silently change composition. + * Example configs run from built packages, so every named package must resolve + * from the examples workspace and every local package must be in the root + * TypeScript project graph. */ import { globSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { dirname, relative, resolve } from 'node:path' import * as yaml from 'js-yaml' +import ts from 'typescript' interface JsExpr { __jsExpr: string } +interface PackageManifest { + name?: string + dependencies?: Record +} + +interface PluginReference { + file: string + name: string +} + const root = resolve(import.meta.dirname, '..') const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { @@ -30,6 +44,7 @@ const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], { exclude: ['.claude/**', 'node_modules/**', 'vendor/**'], }).sort() const errors: string[] = [] +const examplePluginReferences: PluginReference[] = [] for (const file of files) { const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) @@ -42,8 +57,10 @@ for (const file of files) { } } +errors.push(...validateExampleResolution()) + if (errors.length > 0) { - console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.') + console.error('verify-cordis-config: invalid Loader metadata or example package resolution:') for (const error of errors) console.error(`- ${error}`) process.exitCode = 1 } else { @@ -55,6 +72,7 @@ function validateEntry(value: unknown, file: string, path: string): void { errors.push(`${file}${path}: entry must be an object`) return } + recordExamplePlugin(value, file) validateMetadata(value, file, path) if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) { for (let index = 0; index < value.config.length; index++) { @@ -68,6 +86,7 @@ function validateEntry(value: unknown, file: string, path: string): void { const patch = config.patches[index] const patchPath = `${path}.config.patches[${index}]` if (!isRecord(patch)) continue + recordExamplePlugin(patch, file) validateMetadata(patch, file, patchPath) if (!isUnknownArray(patch.insert)) continue for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) { @@ -76,6 +95,83 @@ function validateEntry(value: unknown, file: string, path: string): void { } } +function recordExamplePlugin(entry: Record, file: string): void { + if (file.startsWith('examples/') && typeof entry.name === 'string') { + examplePluginReferences.push({ file, name: entry.name }) + } +} + +function validateExampleResolution(): string[] { + const violations: string[] = [] + const exampleManifest = readManifest('examples/package.json') + const dependencies = exampleManifest.dependencies ?? {} + const localPackages = localPackageDirectories() + const rootReferences = rootProjectReferences() + const requiredPackages = new Map>() + + for (const reference of examplePluginReferences) { + const packageName = packageNameFromSpecifier(reference.name) + if (packageName === undefined) continue + const locations = requiredPackages.get(packageName) ?? new Set() + locations.add(reference.file) + requiredPackages.set(packageName, locations) + } + + for (const [packageName, locations] of requiredPackages) { + if (!(packageName in dependencies)) { + violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`) + } + } + + const localExamplePackages = new Set([ + ...Object.keys(dependencies), + ...requiredPackages.keys(), + ]) + for (const packageName of localExamplePackages) { + const packageDirectory = localPackages.get(packageName) + if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue + const repoPath = relative(root, packageDirectory).replaceAll('\\', '/') + violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`) + } + + return violations +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest +} + +function localPackageDirectories(): Map { + const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }) + const packages = new Map() + for (const manifestPath of manifests) { + const manifest = readManifest(manifestPath) + if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath))) + } + return packages +} + +function rootProjectReferences(): Set { + const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), ts.sys.readFile) + if (config.error !== undefined) { + throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) + } + const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? [] + return new Set(references.flatMap((reference) => { + if (typeof reference.path !== 'string') return [] + return [resolve(root, reference.path)] + })) +} + +function packageNameFromSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined + const segments = specifier.split('/') + if (specifier.startsWith('@')) { + return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined + } + return segments[0] || undefined +} + function validateMetadata(entry: Record, file: string, path: string): void { for (const field of metadataFields) { if (!(field in entry)) continue From b4ab7debb7caf111981c7d37863191c02d8912f3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:43:20 +0800 Subject: [PATCH 25/27] fix(ci): avoid unbound TypeScript reader --- scripts/verify-cordis-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index c4075cb256..88615ee57a 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -152,7 +152,7 @@ function localPackageDirectories(): Map { } function rootProjectReferences(): Set { - const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), ts.sys.readFile) + const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), path => ts.sys.readFile(path)) if (config.error !== undefined) { throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) } From de19c6ca0820cb7bafc48bc8918b9214e7fa9638 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:10:04 +0800 Subject: [PATCH 26/27] fix(examples): run coverage subprocesses from built libs --- packages/support/acp-snapshot/src/harness.ts | 3 +++ .../acp-snapshot/tests/harness.spec.ts | 6 +++-- .../support/acp-snapshot/tests/suite.spec.ts | 6 +++-- packages/support/loader-smoke/src/index.ts | 23 ++++++++++++------- .../loader-smoke/tests/example-launch.spec.ts | 17 +++++++++++++- .../loader-smoke/tests/loader-smoke.spec.ts | 2 ++ scripts/run-gates.ts | 3 +++ 7 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 05d79d3330..e2072b6b17 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -33,6 +33,8 @@ import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' export interface AgentUnderTest { /** The agent bin's SOURCE entry (e.g. `packages/examples/acp-demo/src/bin.ts`); the `lib` bin is derived from it. */ binScript: string + /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */ + libBinScript?: string | undefined /** * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps * it for the sibling `cordis.snapshot.yml` (the keyless replay overlay), so @@ -181,6 +183,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Node, resolving plugins through the example's workspace node_modules → lib. const launch = resolveExampleLaunch({ srcBin: opts.agent.binScript, + libBin: opts.agent.libBinScript, configArgs: ['--config', opts.configPath ?? opts.agent.configPath], tsconfigPath: opts.agent.tsconfigPath, env: { diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 42ccbd7de4..1d953f5e95 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -14,10 +14,12 @@ import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness * assertions read plain `rawStdout`. */ +const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)) const AGENT: AgentUnderTest = { - binScript: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)), + binScript: fakeAgent, + libBinScript: fakeAgent, // The fake bin ignores its config argv; any real path documents the shape. - configPath: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)), + configPath: fakeAgent, tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)), } diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 89af6c6614..4cedc1cdfb 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -32,9 +32,11 @@ import { * spec once with `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1`, then review and commit the resulting tree. */ +const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)) const AGENT = { - binScript: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)), - configPath: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)), + binScript: fakeAgent, + libBinScript: fakeAgent, + configPath: fakeAgent, tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)), } diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index f39c6d678c..edbe39d8ca 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -17,7 +17,6 @@ import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 @@ -54,6 +53,8 @@ export function resolveExampleMode(raw: string | undefined = process.env[EXAMPLE export interface ExampleLaunchOptions { /** Absolute path to the example bin's TypeScript source entry (`/src/bin.ts`); the `lib` bin is derived from it. */ readonly srcBin: string + /** Explicit plain-Node entry for `lib` mode; test fixtures may point this at Node-type-strippable TypeScript. */ + readonly libBin?: string | undefined /** Arguments passed after the bin — the config, positional (`[configPath]`) or flagged (`['--config', configPath]`). */ readonly configArgs?: readonly string[] /** The mode to launch in; defaults to {@link resolveExampleMode} of the environment. */ @@ -78,11 +79,14 @@ export interface ExampleLaunch { /** Derive the built-lib bin (`/lib/.js`) from a source bin (`/src/.ts`). */ function toLibBin(srcBin: string): string { - const marker = '/src/' - const cut = srcBin.lastIndexOf(marker) - if (cut === -1) throw new Error(`resolveExampleLaunch: expected a "/src/" segment in bin path ${JSON.stringify(srcBin)}.`) - const tail = srcBin.slice(cut + marker.length).replace(/\.ts$/, '.js') - return `${srcBin.slice(0, cut)}/lib/${tail}` + const markerLength = '/src/'.length + const cut = Math.max(srcBin.lastIndexOf('/src/'), srcBin.lastIndexOf('\\src\\')) + if (cut === -1) { + throw new Error(`resolveExampleLaunch: expected a "/src/" segment or Windows equivalent in bin path ${JSON.stringify(srcBin)}.`) + } + const separator = srcBin.slice(cut, cut + 1) + const tail = srcBin.slice(cut + markerLength).replace(/\.ts$/, '.js') + return `${srcBin.slice(0, cut)}${separator}lib${separator}${tail}` } /** @@ -108,12 +112,12 @@ export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaun if (options.tsconfigPath === undefined) { throw new Error("resolveExampleLaunch: 'src' mode needs tsconfigPath for the workspace paths map.") } - const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) + const tsxLoader = import.meta.resolve('tsx') env.TSX_TSCONFIG_PATH = options.tsconfigPath return { command: process.execPath, args: [...flags, '--import', tsxLoader, options.srcBin, ...configArgs], env } } - return { command: process.execPath, args: [...flags, toLibBin(options.srcBin), ...configArgs], env } + return { command: process.execPath, args: [...flags, options.libBin ?? toLibBin(options.srcBin), ...configArgs], env } } /** Inputs that vary between real-Loader example smokes. */ @@ -124,6 +128,8 @@ export interface LoaderSmokeOptions { readonly tempDirPrefix: string /** Absolute stdio-agent bin SOURCE path (`/src/bin.ts`); the `lib` bin is derived from it. */ readonly binScript: string + /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */ + readonly libBinScript?: string | undefined /** Absolute real Loader config path. */ readonly configPath: string /** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */ @@ -158,6 +164,7 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { describe('resolveExampleMode', () => { it('defaults absent/empty/src to src', () => { - expect(resolveExampleMode(undefined)).toBe('src') + Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV) + expect(resolveExampleMode()).toBe('src') expect(resolveExampleMode('')).toBe('src') expect(resolveExampleMode('src')).toBe('src') }) @@ -71,6 +72,12 @@ describe('resolveExampleLaunch', () => { expect(env.DSH_HOME).toBe('/tmp/home') }) + it('lib mode: uses an explicit plain-Node bin when provided', () => { + const fixture = '/repo/packages/support/loader-smoke/tests/fixture.ts' + const { args } = resolveExampleLaunch({ srcBin: fixture, libBin: fixture, mode: 'lib' }) + expect(args).toContain(fixture) + }) + it('prepends --expose-internals when requested', () => { const { args } = resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'lib', exposeInternals: true }) expect(args[0]).toBe('--expose-internals') @@ -84,6 +91,14 @@ describe('resolveExampleLaunch', () => { expect(args).toContain('/repo/src/packages/examples/acp-demo/lib/bin.js') }) + it('lib mode: derives the built bin from a Windows source path', () => { + const { args } = resolveExampleLaunch({ + srcBin: String.raw`D:\repo\src\packages\examples\acp-demo\src\bin.ts`, + mode: 'lib', + }) + expect(args).toContain(String.raw`D:\repo\src\packages\examples\acp-demo\lib\bin.js`) + }) + it('lib mode: throws when the bin has no /src/ segment', () => { expect(() => resolveExampleLaunch({ srcBin: '/repo/lib/bin.js', mode: 'lib' })).toThrow(/"\/src\/" segment/) }) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 991a1a64af..b99d810188 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -44,6 +44,7 @@ describe('runLoaderSmoke', () => { label: 'failure fixture', tempDirPrefix: 'loader-smoke-fail-', binScript: fixture('fail'), + libBinScript: fixture('fail'), configPath, tsconfigPath, })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed') @@ -54,6 +55,7 @@ describe('runLoaderSmoke', () => { label: 'hanging fixture', tempDirPrefix: 'loader-smoke-hang-', binScript: fixture('hang'), + libBinScript: fixture('hang'), configPath, tsconfigPath, processTimeoutMs: 100, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 378cafed90..ed575858a6 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -163,6 +163,7 @@ function gatesForMode(selected: Mode): Gate[] { ] case 'ci-coverage': return [ + pnpmScript('build', 'build'), coverageGate(), ] case 'ci-snapshot': @@ -282,6 +283,8 @@ function coverageGate(): Gate { ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), ], { label: 'test:coverage', + env: { DSH_EXAMPLE_MODE: 'lib' }, + needs: ['build'], }) } From 6bc8ab5c573741d2a06040c644f9bd2a62b31c66 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:39:20 +0800 Subject: [PATCH 27/27] fix: verify-package-paths --- packages/support/loader-smoke/tests/example-launch.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/support/loader-smoke/tests/example-launch.spec.ts b/packages/support/loader-smoke/tests/example-launch.spec.ts index bbecc1f545..20520e6fb6 100644 --- a/packages/support/loader-smoke/tests/example-launch.spec.ts +++ b/packages/support/loader-smoke/tests/example-launch.spec.ts @@ -73,7 +73,7 @@ describe('resolveExampleLaunch', () => { }) it('lib mode: uses an explicit plain-Node bin when provided', () => { - const fixture = '/repo/packages/support/loader-smoke/tests/fixture.ts' + const fixture = '/repo/fixture.ts' const { args } = resolveExampleLaunch({ srcBin: fixture, libBin: fixture, mode: 'lib' }) expect(args).toContain(fixture) })