From f038780ff65691efcce782461848c1b6fdfa67aa Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 14:47:29 +0800 Subject: [PATCH] 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" },