Merge remote-tracking branch 'origin/token-meter-service' into compact-post-step-overflow-recovery
# Conflicts: # docs/core-data-structures/compaction.md # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md # docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md # examples/coding-agent/cordis.yml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/src/automatic.ts # packages/compact/compact-basic/src/config.ts # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts
This commit is contained in:
@@ -24,7 +24,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx
|
|||||||
| ctx key | Package family | Role |
|
| ctx key | Package family | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls |
|
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls |
|
||||||
| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware request/surface pressure per model |
|
| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure |
|
||||||
| `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution |
|
| `ctx.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.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 |
|
| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ flowchart LR
|
|||||||
| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
|
| 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.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
|
||||||
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-model/session replay folds; pressure consumers share immutable revisioned measurements. |
|
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |
|
||||||
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
|
| `ctx.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.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. |
|
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. |
|
||||||
|
|||||||
@@ -226,8 +226,10 @@ Requires: `llm` · `tokenMeter`
|
|||||||
```ts config-catalog
|
```ts config-catalog
|
||||||
/** Basic compaction configuration; every common field has a deployment default. */
|
/** Basic compaction configuration; every common field has a deployment default. */
|
||||||
export interface BasicCompactConfig {
|
export interface BasicCompactConfig {
|
||||||
/** Field-wise pressure/retention overrides keyed by configured token-meter model name. */
|
/** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */
|
||||||
models?: Record<string, ModelCompactConfig>
|
thresholdRatio?: number
|
||||||
|
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
|
||||||
|
retainTokens?: number
|
||||||
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
|
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
|
||||||
summarizationModel?: string
|
summarizationModel?: string
|
||||||
/** Provider generation cap for summarization. Defaults to `8192`. */
|
/** Provider generation cap for summarization. Defaults to `8192`. */
|
||||||
@@ -239,17 +241,9 @@ export interface BasicCompactConfig {
|
|||||||
/** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
|
/** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
|
||||||
auto?: boolean
|
auto?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Optional pressure and retention policy for one metered model. */
|
|
||||||
export interface ModelCompactConfig {
|
|
||||||
/** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */
|
|
||||||
thresholdRatio?: number
|
|
||||||
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
|
|
||||||
retainTokens?: number
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/compact/compact-basic/src/types.ts:16`](../packages/compact/compact-basic/src/types.ts)
|
Source: [`packages/compact/compact-basic/src/types.ts:8`](../packages/compact/compact-basic/src/types.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-fs-local`
|
## `@deepseek-ai/dsh-fs-local`
|
||||||
|
|
||||||
@@ -877,20 +871,12 @@ Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/ti
|
|||||||
```ts config-catalog
|
```ts config-catalog
|
||||||
/** Token-meter plugin configuration. */
|
/** Token-meter plugin configuration. */
|
||||||
export interface TokenMeterConfig {
|
export interface TokenMeterConfig {
|
||||||
/** Built-in field overrides and custom model profiles, keyed by routed model name. */
|
/** Service-wide context-window capacity in tokens. Defaults to `128000`. */
|
||||||
models?: Record<string, ModelTokenMeterConfig>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Optional pricing fields for one configured model. */
|
|
||||||
export interface ModelTokenMeterConfig {
|
|
||||||
/** Provider context-window capacity in tokens. Required for a custom model. */
|
|
||||||
contextWindow?: number
|
contextWindow?: number
|
||||||
/** Heuristic text density in characters per token. Defaults to `4`. */
|
|
||||||
charsPerToken?: number
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/llm/token-meter/src/types.ts:19`](../packages/llm/token-meter/src/types.ts)
|
Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-tool-bash`
|
## `@deepseek-ai/dsh-tool-bash`
|
||||||
|
|
||||||
|
|||||||
@@ -261,13 +261,16 @@ Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/
|
|||||||
|
|
||||||
## `ctx.tokenMeter` — `TokenMeterService`
|
## `ctx.tokenMeter` — `TokenMeterService`
|
||||||
|
|
||||||
Concrete registry and replay owner for all configured model meters.
|
Replay owner for one service-wide estimator and isolated per-session folds.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
resolve(model: string): ModelTokenMeter
|
measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement
|
||||||
|
estimateMessage(message: Message): number
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/llm/token-meter/src/index.ts:145`](../../packages/llm/token-meter/src/index.ts)
|
Types: [Message](../core-data-structures/core.md)
|
||||||
|
|
||||||
|
Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-meter/src/index.ts)
|
||||||
|
|
||||||
## `ctx.tools` — `ToolRegistry`
|
## `ctx.tools` — `ToolRegistry`
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Automatic callers state why policy is running; implementations may treat confirm
|
|||||||
export type CompactionTrigger = 'pressure' | 'context-overflow'
|
export type CompactionTrigger = 'pressure' | 'context-overflow'
|
||||||
```
|
```
|
||||||
|
|
||||||
`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: `dsh-compact-basic` resolves the durable routed model through [`ctx.tokenMeter`](token-meter.md), whose model-bound handle owns estimation and replay, while the backend owns retention, event sequencing, and summarization.
|
`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration.
|
||||||
|
|
||||||
Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Failed-request recovery runs through `agent/request-error` after the failed step closes, and authorizes a fresh numbered-step retry only when the surface replacement generation advances. 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 thresholds, retained-tail policy, overflow caps, and failure handling.
|
Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Failed-request recovery runs through `agent/request-error` after the failed step closes, and authorizes a fresh numbered-step retry only when the surface replacement generation advances. 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 thresholds, retained-tail policy, overflow caps, and failure handling.
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export interface SurfaceNode {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`SurfaceNode` is positional state, not durable identity. A replacement can remove a caller-retained node or make a copied `next` stale; consumers that cross a surface mutation validate membership and resolve successors from `Session.surface.nodes`. `SurfaceManager.replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite.
|
`SurfaceNode` is positional state, not durable identity. A replacement can remove a caller-retained node or make a copied `next` stale; consumers that cross a surface mutation validate membership and answer positional queries from `Session.surface.nodes`. `SurfaceManager.replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite.
|
||||||
|
|
||||||
### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay
|
### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Token Meter
|
# Token Meter
|
||||||
|
|
||||||
`@deepseek-ai/dsh-token-meter` exposes detached replay measurements for request pressure and positional surface pricing. Scalar and surface snapshots carry the number of durable events consumed as `logRevision`; consumers compare revisions before making a joint decision.
|
`@deepseek-ai/dsh-token-meter` exposes one detached replay snapshot for request pressure and positional surface pricing. `logRevision` is the number of durable events consumed for every field in the measurement.
|
||||||
|
|
||||||
Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts)
|
Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts)
|
||||||
|
|
||||||
@@ -8,8 +8,6 @@ Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter
|
|||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
interface TokenMeasurement {
|
interface TokenMeasurement {
|
||||||
/** Model profile used for every heuristic component. */
|
|
||||||
readonly model: string
|
|
||||||
/** Number of durable events consumed; equal to the next unread event seq. */
|
/** Number of durable events consumed; equal to the next unread event seq. */
|
||||||
readonly logRevision: number
|
readonly logRevision: number
|
||||||
/** Provider or heuristic anchor used for this measurement. */
|
/** Provider or heuristic anchor used for this measurement. */
|
||||||
@@ -18,10 +16,14 @@ interface TokenMeasurement {
|
|||||||
readonly surfaceDeltaTokens: number
|
readonly surfaceDeltaTokens: number
|
||||||
/** Non-negative current request-and-response pressure. */
|
/** Non-negative current request-and-response pressure. */
|
||||||
readonly totalTokens: number
|
readonly totalTokens: number
|
||||||
|
/** Total heuristic tokens across the current surface. */
|
||||||
|
readonly surfaceTokens: number
|
||||||
|
/** Current surface nodes in positional head-to-tail order. */
|
||||||
|
readonly nodes: readonly TokenSurfaceNode[]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`baseline.kind === 'usage'` means a successful provider call has the same model and canonical envelope. `estimated` means the meter repriced the complete envelope and surface. Signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching provider or estimated anchor.
|
`baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope and its total is no lower than that call's full heuristic anchor. `estimated` means no reusable conservative usage anchor exists, so the service priced the complete envelope and surface with its fixed heuristic. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor. `totalTokens` remains request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of the node prices.
|
||||||
|
|
||||||
## `TokenSurfaceNode`
|
## `TokenSurfaceNode`
|
||||||
|
|
||||||
@@ -34,19 +36,4 @@ interface TokenSurfaceNode {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## `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.
|
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.
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-15-replay-token-meter-service.md: 079eb58f38a69a40b3f47a23e72d159f7025d285
|
2026-07-15-replay-token-meter-service.md: e3dc2debe02134e8a03ff1cd34f0a352b17d7eb0
|
||||||
2026-07-15-replay-token-meter-service.zh.md: 7c3c9ce47ad81029b03747ddb17b87dc55def8e7
|
2026-07-15-replay-token-meter-service.zh.md: 4ba5f6569d61f6b5760ab0c34ecbddde6cd84e83
|
||||||
|
|||||||
@@ -6,52 +6,55 @@ English | [中文](2026-07-15-replay-token-meter-service.zh.md)
|
|||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of one model's window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse accounting from the wrong model.
|
Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of the configured context window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting.
|
||||||
|
|
||||||
Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch models, old logs can lack chunk provenance, and provider fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines exact anchors with conservative model-specific repricing and exposes the log revision consumed by each result.
|
Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch models, old logs can lack chunk provenance, and provider fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines the latest exact anchor with conservative heuristic repricing and exposes the log revision consumed by each result.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
### One concrete LLM-family service
|
### One concrete LLM-family service
|
||||||
|
|
||||||
`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. Its public entry point resolves an exact model name to a stable `ModelTokenMeter`; unknown names throw `TokenMeterError` with `TOKEN_METER_MODEL_UNCONFIGURED` instead of inheriting a universal window.
|
`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `contextWindow`, `measure(session, requestHeader?)`, and `estimateMessage(message)`; consumers call the singleton service directly.
|
||||||
|
|
||||||
The built-in `deepseek-v4-flash` and `deepseek-v4-pro` profiles use a 128,000-token context window and four characters per estimated token. `models` overrides merge field-by-field. A custom name requires `contextWindow`, while `charsPerToken` defaults to four. Direct construction reports typed profile errors; Loader mounts first apply the package's Schemastery shape validation.
|
The service has one `contextWindow`, defaulting to 128,000 tokens and configurable as a positive integer. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, density settings, tokenizer backends, or language-specific strategies.
|
||||||
|
|
||||||
### Model-bound replay folds
|
### Per-session replay folds
|
||||||
|
|
||||||
Each model/session pair owns an isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical request headers and deltas, step boundaries, surface appends and replacements, assistant usage, and assistant-chunk provenance. A malformed next event fails transactionally and remains unread rather than partially mutating state.
|
Each session owns one isolated incremental fold. Active folds advance from `session/event`; every read catches up through the durable tail, so listener ordering, seeded sessions, and service reload do not change the answer. The fold tracks canonical request headers and deltas, step boundaries, surface appends and replacements, assistant usage, and assistant-chunk provenance. A malformed next event fails transactionally and remains unread rather than partially mutating state.
|
||||||
|
|
||||||
`measure(session, requestHeader?)` returns scalar pressure. `measureSurface(session)` returns positional per-node prices for retention and replacement decisions. `estimateMessage(message)` applies the handle's profile without session state. Results are detached, deeply immutable snapshots carrying `logRevision`; a consumer compares scalar and surface revisions before making one decision.
|
`measure(session, requestHeader?)` synchronizes the fold once and returns scalar pressure together with positional per-node prices. `totalTokens` remains request-and-response pressure; `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override changes pressure pricing only, while the surface fields always describe the current session. `estimateMessage(message)` applies the fixed heuristic without session state. Each result is one detached, deeply immutable snapshot carrying one `logRevision`. Every measurement clones the current nodes and is therefore O(surface).
|
||||||
|
|
||||||
Provider usage is reused only when the handle's model and canonical request envelope equal the successful-call anchor. Any system, prefix, tool, or call-config change causes complete repricing under the requested model. Surface changes remain a signed delta from a matching anchor, including negative values after a shrinking replacement. A success by another model changes the shared surface but never overwrites this model's anchor.
|
Provider usage is reused only when the measured canonical request envelope equals the latest successful-call anchor. Any model, system, prefix, tool, or call-config change causes complete heuristic repricing. Surface changes remain a signed delta from a matching anchor, including negative values after a shrinking replacement. A later successful request replaces the earlier anchor, including across model switches.
|
||||||
|
|
||||||
Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reasoning is not added a second time. Every successful model call records an `assistant/message`, including content-less and max-token calls, with its exact earlier chunk seqs. An explicit empty provenance list means a known empty provider stream; absent legacy provenance conservatively treats the durable assistant output as provider output.
|
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
|
### Compact-basic consumes, but does not own, measurement
|
||||||
|
|
||||||
`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The conversation model's meter consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection.
|
`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The singleton service consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection.
|
||||||
|
|
||||||
Every metered model receives a compact policy with defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, summarization model `''`, maximum summary output `8192`, one extra pressure-compaction attempt, one context-overflow retry, 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`.
|
Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization; any intervening durable append changes `logRevision` and prevents replacement.
|
||||||
|
|
||||||
Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope under the model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; a durable unknown routed model remains an exact typed error. Canonical overflow recovery uses the same meter for forced range selection, and retries only after a proven surface replacement.
|
Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, summarization model `''`, maximum summary output `8192`, one extra pressure-compaction attempt, one context-overflow retry, and automatic triggering enabled. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. Empty summarization model resolves the latest logged routed model, then `AgentOptions.model`.
|
||||||
|
|
||||||
|
Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope produced under the model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed model name can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement.
|
||||||
|
|
||||||
## Testing
|
## 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, actual routing, retention, convergence, forced overflow, and transaction rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order.
|
Unit coverage pins service configuration, fixed estimation, envelope invalidation, latest-anchor replacement across model switches, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, unified snapshot detachment and deep immutability, surface-total equality, listener ordering, reload, compact defaults, actual routing, one-call automatic decisions, retention, convergence, forced overflow, generation proof, and log-revision rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order.
|
||||||
|
|
||||||
## Alternatives considered
|
## 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.
|
- **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.
|
- **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration.
|
||||||
- **Give unknown models a 128,000-token fallback** — rejected because a plausible but wrong capacity can trigger destructive policy at the wrong point. Unknown routed names fail with their exact name.
|
- **Keep model-keyed windows and density profiles** — rejected because the deployment currently has one context policy and one estimator. Model registries, unknown-model failures, and configurable density add branches without a second behavior to select.
|
||||||
- **Copy complete history into each scalar result** — rejected because below-threshold reads are common. Immutable revisioned scalars and a separate surface snapshot preserve consistency without an O(history) copy.
|
- **Keep separate scalar and surface measurements** — rejected because callers would need two reads and revision matching for one decision. A scalar-only read could avoid cloning nodes below threshold, but the split API introduces a caller-side race window; the unified snapshot accepts O(surface) cloning in exchange for coherence.
|
||||||
- **Treat provider usage as portable between models or envelopes** — rejected because tokenization, context capacity, tools, prefixes, and call config are model/request facts. Mismatch reprices the whole current request.
|
- **Treat provider usage as portable between envelopes** — rejected because model, tools, prefixes, and call config are request facts. Mismatch reprices the whole current request.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- Token pressure has one replay-aware owner that compaction and future plugins can share.
|
- 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.
|
- The default makes the bundled composition usable with two zero-config plugin entries; deployments override one context capacity when needed.
|
||||||
- Heuristic density and provider usage remain estimates of provider behavior. Maintainers must update built-in profiles and overflow wording as models evolve.
|
- Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer.
|
||||||
|
- Every measurement clones the current positional surface and therefore costs O(surface), including pressure checks that finish below threshold.
|
||||||
- Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure.
|
- Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure.
|
||||||
- Post-step pressure reads the exact logged routing/tools/prefix boundary; provider overflow classification remains the adapter-maintained backstop for requests rejected before a successful usage anchor.
|
- Post-step pressure reads the exact logged routing/tools/prefix boundary; provider overflow classification remains the adapter-maintained backstop for requests rejected before a successful usage anchor.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RFC: 重放式 token 计量服务
|
# RFC: 回放式 token 计量服务
|
||||||
|
|
||||||
Status: implemented
|
Status: implemented
|
||||||
|
|
||||||
@@ -6,52 +6,55 @@ Status: implemented
|
|||||||
|
|
||||||
## 问题
|
## 问题
|
||||||
|
|
||||||
上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了某个模型多少上下文窗口?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现重放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方错误复用其他模型的核算结果。
|
上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了已配置上下文窗口的多少容量?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。
|
||||||
|
|
||||||
提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换模型,旧日志可能缺少 chunk 来源,提供方字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把精确锚点与保守的逐模型重新定价结合起来,并公开每个结果已经消费的日志修订号。
|
提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换模型,旧日志可能缺少分片来源,提供方字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。
|
||||||
|
|
||||||
## 决策
|
## 决策
|
||||||
|
|
||||||
### 一个具体的 LLM 家族服务
|
### 一个具体的 LLM 家族服务
|
||||||
|
|
||||||
`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体 package,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。公开入口把精确模型名解析为稳定的 `ModelTokenMeter`;未知名称抛出带 `TOKEN_METER_MODEL_UNCONFIGURED` 的 `TokenMeterError`,而不是继承通用窗口。
|
`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `contextWindow`、`measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。
|
||||||
|
|
||||||
内置的 `deepseek-v4-flash` 与 `deepseek-v4-pro` profile 都采用 128,000 token 上下文窗口,以及每 token 四个字符的估算密度。`models` 覆盖按字段合并。自定义名称必须提供 `contextWindow`,而 `charsPerToken` 默认为四。直接构造会报告类型化 profile 错误;Loader 挂载则先应用 package 的 Schemastery 形状校验。
|
服务只有一个 `contextWindow`,默认值为 128,000 token,并允许配置为正整数。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、密度设置、分词器后端或语言专用策略。
|
||||||
|
|
||||||
### 绑定模型的重放折叠
|
### 逐会话回放折叠
|
||||||
|
|
||||||
每个模型/会话对都有隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范请求头及其增量、步骤边界、表层追加与替换、assistant usage,以及 assistant chunk 来源。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。
|
每个会话都有一个隔离的增量折叠。活跃折叠通过 `session/event` 前进;每次读取都会追到持久日志尾部,因此监听器顺序、种子会话与服务重载不会改变答案。折叠跟踪规范请求头及其增量、步骤边界、表层追加与替换、assistant usage,以及 assistant 分片来源。下一个畸形事件会以事务方式失败并保持未读,不会让状态只修改一半。
|
||||||
|
|
||||||
`measure(session, requestHeader?)` 返回标量压力。`measureSurface(session)` 返回用于保留与替换决策的逐位置节点价格。`estimateMessage(message)` 不依赖会话状态,直接应用该 handle 的 profile。结果是分离且深度不可变的快照,并携带 `logRevision`;消费者在一次联合决策前比较标量与表层修订号。
|
`measure(session, requestHeader?)` 只同步一次折叠,并在返回标量压力的同时给出逐位置节点价格。`totalTokens` 仍表示请求与响应压力;`surfaceTokens` 是仅针对表层的启发式总量,并等于 `nodes[].tokens` 之和。`requestHeader` 覆盖只改变压力定价,表层字段始终描述当前会话。`estimateMessage(message)` 不依赖会话状态,直接应用固定启发式规则。每个结果都是一个分离且深度不可变的快照,只携带一个 `logRevision`。每次计量都会复制当前节点,因此成本为 O(surface)。
|
||||||
|
|
||||||
只有当 handle 的模型与规范请求信封都等于成功调用锚点时,服务才复用提供方 usage。系统提示词、前缀、工具或调用配置任一变化都会在请求模型下重新定价完整当前请求。表层变化相对匹配锚点保留有符号增量,包括缩小替换后的负值。其他模型的成功调用会改变共享表层,但绝不会覆盖当前模型的锚点。
|
只有当待计量的规范请求信封等于最近一次成功调用的锚点时,服务才复用提供方 usage。模型、系统提示词、前缀、工具或调用配置任一变化都会触发完整的启发式重新定价。表层变化相对匹配锚点保留有符号增量,包括缩小替换后的负值。后续成功请求会替换先前锚点,模型切换时也一样。
|
||||||
|
|
||||||
Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 求和,不会再次加入推理计数。每次成功模型调用都会记录 `assistant/message`,包括无内容调用与达到 token 上限的调用,并带上精确的更早 chunk seq。显式空来源列表表示已知为空的提供方流;旧日志中缺失的来源则保守地把持久 assistant 输出视为提供方输出。
|
Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 求和,不会再次加入推理计数。每次成功模型调用都会记录 `assistant/message`,包括无内容调用与达到 token 上限的调用,并带上精确的更早分片 seq。显式空来源列表表示已知为空的提供方流;旧日志中缺失的来源则保守地把持久 assistant 输出视为提供方输出。
|
||||||
|
|
||||||
### compact-basic 消费计量,但不拥有计量
|
### compact-basic 消费计量,但不拥有计量
|
||||||
|
|
||||||
`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。会话模型的 meter 一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝。
|
`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。单例服务一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。
|
||||||
|
|
||||||
每个已计量模型都会获得默认压缩策略:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、摘要模型 `''`、摘要最大输出 `8192`、一次额外压力压缩尝试、一次上下文溢出重试,以及启用自动触发。逐模型压缩覆盖按字段合并 `thresholdRatio` 与 `retainTokens`;保留值必须小于最终阈值。空摘要模型先解析最近记录的实际路由模型,再使用 `AgentOptions.model`。
|
自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务先追加持久 `compact/start` 锁,再执行一次计量,并在异步摘要完成后再次计量;期间任何持久追加都会改变 `logRevision`,从而阻止替换。
|
||||||
|
|
||||||
自动压力检查运行在 `agent/post-step`,并使用 `agent/request` 实际选择的模型计量规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;持久记录的未知路由模型仍抛出带精确名称的类型化错误。规范化溢出恢复使用同一 meter 强制选择范围,并且只有在表层替换得到证明后才重试。
|
压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、摘要模型 `''`、摘要最大输出 `8192`、一次额外压力压缩尝试、一次上下文溢出重试,以及启用自动触发。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。空摘要模型先解析最近记录的实际路由模型,再使用 `AgentOptions.model`。
|
||||||
|
|
||||||
|
自动压力检查运行在 `agent/post-step`,并计量 `agent/request` 实际所选模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由模型名都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
单元覆盖固定 profile、按字段覆盖、自定义与未知模型、信封失效、模型切换、有无 usage 的路径、种子追加/替换重放、有符号增量、来源模式、畸形边界、不可变快照、监听器顺序、重载、压缩默认值、实际路由、保留、收敛、强制溢出与事务回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic package 名称。
|
单元覆盖固定服务配置、固定估算、信封失效、模型切换时替换最新锚点、有无 usage 的路径、种子追加/替换回放、有符号增量、来源模式、畸形边界、统一快照的分离性与深度不可变性、表层总量相等性、监听器顺序、重载、压缩默认值、实际路由、自动决策单次调用、保留、收敛、强制溢出、替换代次证明与日志修订回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic 包名称。
|
||||||
|
|
||||||
## 考虑过的替代方案
|
## 考虑过的替代方案
|
||||||
|
|
||||||
- **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费者与重放语义;它还会强迫每个压缩器暴露同一套无关 API。
|
- **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。
|
||||||
- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的 package 与配置。
|
- **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的包与配置。
|
||||||
- **给未知模型提供 128,000 token 回退**——不予采纳,因为看似合理但错误的容量会在错误时点触发破坏性策略。未知路由名称会携带精确名称失败。
|
- **保留模型键控的窗口与密度 profile**——不予采纳,因为当前部署只有一种上下文策略与一个估算器。模型注册表、未知模型错误和可配置密度只增加分支,却没有第二种行为可供选择。
|
||||||
- **在每个标量结果中复制完整历史**——不予采纳,因为低于阈值的读取很常见。不可变且带修订号的标量与独立表层快照,在不进行 O(history) 复制的情况下保持一致性。
|
- **保留独立的标量与表层计量**——不予采纳,因为消费方必须为一次决策执行两次读取并匹配修订号。仅读取标量可以避免在低于阈值时复制节点,但拆分 API 会在消费方引入竞态窗口;统一快照接受 O(surface) 复制成本,以换取结果一致性。
|
||||||
- **在模型或信封之间移用提供方 usage**——不予采纳,因为分词、上下文容量、工具、前缀与调用配置都是模型/请求事实。不匹配时会重新定价完整当前请求。
|
- **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。
|
||||||
|
|
||||||
## 后果
|
## 后果
|
||||||
|
|
||||||
- Token 压力拥有一个可供压缩与未来插件共享的重放感知所有者。
|
- Token 压力拥有一个可供压缩与未来插件共享的回放感知所有者。
|
||||||
- 默认值让内置 DeepSeek 组合只需两个零配置插件条目即可使用,而自定义模型必须声明唯一不能安全猜测的事实:上下文容量。
|
- 默认值让内置组合只需两个零配置插件条目即可使用;部署需要时只覆盖一个上下文容量。
|
||||||
- 启发式密度与提供方 usage 仍然只是提供方行为的估计。随着模型演进,维护者必须更新内置 profile 与溢出措辞。
|
- 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。
|
||||||
- 遇到畸形持久边界时,计量会明确失败。这会把损坏的重放转化为具名集成错误,而不是让压力静默漂移。
|
- 每次计量都会复制当前的位置表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。
|
||||||
|
- 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。
|
||||||
- post-step 压力检查读取精确记录的路由、工具与前缀边界;对于在成功 usage 锚点出现前就被拒绝的请求,提供方溢出分类仍是由适配器维护的兜底路径。
|
- post-step 压力检查读取精确记录的路由、工具与前缀边界;对于在成功 usage 锚点出现前就被拒绝的请求,提供方溢出分类仍是由适配器维护的兜底路径。
|
||||||
|
|||||||
@@ -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
|
### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend
|
||||||
|
|
||||||
An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the standalone service lets multiple consumers share one model/session replay fold.
|
An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold.
|
||||||
|
|
||||||
`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It resolves only the latest durable routed request model; no header means no work, while a named unconfigured model produces the token meter's exact typed error. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options, and records the model after any `llm/stream` routing.
|
`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed model name uses the singleton estimator. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before lock acquisition, summarization, or log mutation. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options, and records the model after any `llm/stream` routing.
|
||||||
|
|
||||||
### Automatic pressure runs after successful durable step work
|
### Automatic pressure runs after successful durable step work
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface
|
|||||||
|
|
||||||
Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The post-step check can compact early closed tool pairs before continuation opens the next step, and provider-confirmed overflow remains the backstop when a request crosses the limit first.
|
Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The post-step check can compact early closed tool pairs before continuation opens the next step, and provider-confirmed overflow remains the backstop when a request crosses the limit first.
|
||||||
|
|
||||||
`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership, positional successors, and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.
|
`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.
|
||||||
|
|
||||||
A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes.
|
A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes.
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint
|
|||||||
|
|
||||||
### Approximate convergence invariant
|
### Approximate convergence invariant
|
||||||
|
|
||||||
`resolveConfig` supplies usable common defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization-model override, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 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 pressure 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. Overflow bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit.
|
`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization-model override, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional top-level `thresholdRatio` and `retainTokens` override the policy for the token meter's single context window; retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure 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. Overflow bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit.
|
||||||
|
|
||||||
### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary
|
### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary
|
||||||
|
|
||||||
@@ -113,9 +113,9 @@ Two failure paths, both documented:
|
|||||||
- **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.
|
- **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.
|
||||||
- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload.
|
- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload.
|
||||||
- **`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.
|
- **`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-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 answers both edges from one per-cut balance sequence 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.
|
- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged.
|
||||||
- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; bundled DeepSeek profiles and compact defaults make the pair usable without repeated numeric policy.
|
- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Status: proposed
|
|||||||
|
|
||||||
The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas.
|
The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas.
|
||||||
|
|
||||||
`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads `prev`; compact's sole `next` read is the successor of an array position. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate.
|
`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads either link: compact's tool-pairing balance answers from per-cut balances cached in surface order. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate.
|
||||||
|
|
||||||
The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid.
|
The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid.
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,12 @@
|
|||||||
Verify your work by running the code or tests. Keep answers brief and
|
Verify your work by running the code or tests. Keep answers brief and
|
||||||
factual.
|
factual.
|
||||||
|
|
||||||
# Replay-aware request pressure for the bundled DeepSeek model profiles.
|
# Replay-aware request pressure with one service-wide context window.
|
||||||
- id: token-meter
|
- id: token-meter
|
||||||
name: '@deepseek-ai/dsh-token-meter'
|
name: '@deepseek-ai/dsh-token-meter'
|
||||||
|
|
||||||
# Summarize an older range after measured pressure or a canonical provider overflow.
|
# Summarize an older range after measured pressure or a canonical provider overflow.
|
||||||
# Built-in policies provide pressure, retention, and one overflow-retry default.
|
# Service-wide policy provides pressure, retention, and one overflow-retry default.
|
||||||
- id: compact-basic
|
- id: compact-basic
|
||||||
name: '@deepseek-ai/dsh-compact-basic'
|
name: '@deepseek-ai/dsh-compact-basic'
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
|
|||||||
ctx = await codingHarness(workdir, {
|
ctx = await codingHarness(workdir, {
|
||||||
persona: SYSTEM_PROMPT,
|
persona: SYSTEM_PROMPT,
|
||||||
tokenMeter: {
|
tokenMeter: {
|
||||||
models: {
|
contextWindow: 2000,
|
||||||
'deepseek-v4-flash': { contextWindow: 2000 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
compact: {
|
compact: {
|
||||||
models: {
|
thresholdRatio: 0.5,
|
||||||
'deepseek-v4-flash': { thresholdRatio: 0.5, retainTokens: 400 },
|
retainTokens: 400,
|
||||||
},
|
|
||||||
summarizationModel: '',
|
summarizationModel: '',
|
||||||
maxTokens: 1024,
|
maxTokens: 1024,
|
||||||
compactionRetries: 1,
|
compactionRetries: 1,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export interface CodingHarnessOptions {
|
|||||||
* compaction plugin (the default suites run without it).
|
* compaction plugin (the default suites run without it).
|
||||||
*/
|
*/
|
||||||
compact?: BasicCompactConfig
|
compact?: BasicCompactConfig
|
||||||
/** Optional meter profiles loaded before compact-basic. */
|
/** Optional token-meter capacity loaded before compact-basic. */
|
||||||
tokenMeter?: TokenMeterConfig
|
tokenMeter?: TokenMeterConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
|
|||||||
await ctx.plugin(ToolBash)
|
await ctx.plugin(ToolBash)
|
||||||
await ctx.plugin(ToolTodo)
|
await ctx.plugin(ToolTodo)
|
||||||
// Compaction is opt-in: only the compaction e2e loads the reusable meter and
|
// Compaction is opt-in: only the compaction e2e loads the reusable meter and
|
||||||
// backend, with a lowered profile window so a short real session crosses the threshold.
|
// backend, with a lower context window so a short real session crosses the threshold.
|
||||||
if (options.compact !== undefined) {
|
if (options.compact !== undefined) {
|
||||||
await ctx.plugin(TokenMeterService, options.tokenMeter)
|
await ctx.plugin(TokenMeterService, options.tokenMeter)
|
||||||
await ctx.plugin(BasicCompactService, options.compact)
|
await ctx.plugin(BasicCompactService, options.compact)
|
||||||
|
|||||||
@@ -8,25 +8,25 @@ This is the implementation tier of the compaction capability — see the [interf
|
|||||||
|
|
||||||
This backend owns the compaction policy:
|
This backend owns the compaction policy:
|
||||||
|
|
||||||
- **Measurement** — the latest durable routed request model's `ModelTokenMeter` prices the canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
|
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||||
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||||
- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue, while an actually routed model without a meter profile fails the otherwise-successful turn with the typed meter error.
|
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error.
|
||||||
|
|
||||||
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
|
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||||
|
|
||||||
## Config (`BasicCompactConfig`)
|
## Config (`BasicCompactConfig`)
|
||||||
|
|
||||||
Every common setting is optional. Every model known to `ctx.tokenMeter` receives the default compact policy lazily; named overrides merge only the fields supplied and must name a configured meter profile.
|
Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected.
|
||||||
|
|
||||||
| Key | Required | Meaning |
|
| Key | Required | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `models.<model>.thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
|
| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
|
||||||
| `models.<model>.retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
|
||||||
@@ -117,7 +117,7 @@ Rules:
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- **Meter accuracy follows the selected profile** — missing provider usage falls back to the token meter's configured character density and structural overhead.
|
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
|
||||||
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
|
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
|
||||||
- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix.
|
- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix.
|
||||||
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
|
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
|
||||||
|
|||||||
@@ -1,65 +1,75 @@
|
|||||||
/**
|
/**
|
||||||
* Runtime defaulting and per-model policy validation for compact-basic.
|
* Runtime defaulting and policy validation for compact-basic.
|
||||||
*
|
*
|
||||||
* @module @deepseek-ai/dsh-compact-basic/config
|
* @module @deepseek-ai/dsh-compact-basic/config
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ModelTokenMeter, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||||
import type {
|
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||||
BasicCompactConfig,
|
|
||||||
ModelCompactConfig,
|
|
||||||
ResolvedConfig,
|
|
||||||
ResolvedModelCompactConfig,
|
|
||||||
} from './types.ts'
|
|
||||||
|
|
||||||
/** Default request-pressure fraction for every metered model. */
|
/** Default request-pressure fraction of the token meter's context window. */
|
||||||
const DEFAULT_THRESHOLD_RATIO = 0.8
|
const DEFAULT_THRESHOLD_RATIO = 0.8
|
||||||
|
|
||||||
/** Default verbatim-tail fraction of a model's context window. */
|
/** Default verbatim-tail fraction of the token meter's context window. */
|
||||||
const DEFAULT_RETAIN_RATIO = 0.16
|
const DEFAULT_RETAIN_RATIO = 0.16
|
||||||
|
|
||||||
|
/** Complete public configuration key set. */
|
||||||
|
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
'thresholdRatio',
|
||||||
|
'retainTokens',
|
||||||
|
'summarizationModel',
|
||||||
|
'maxTokens',
|
||||||
|
'compactionRetries',
|
||||||
|
'maxOverflowRetries',
|
||||||
|
'auto',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Reject stale or misspelled keys before defaults can hide them. */
|
||||||
|
function validateConfigKeys(config: BasicCompactConfig): void {
|
||||||
|
for (const key of Object.keys(config)) {
|
||||||
|
if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`BasicCompactConfig: unknown key "${key}" `
|
||||||
|
+ '(allowed: thresholdRatio, retainTokens, summarizationModel, maxTokens, '
|
||||||
|
+ 'compactionRetries, maxOverflowRetries, auto)',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve common defaults and validate every named model override.
|
* Resolve defaults and validate the service-wide compaction policy.
|
||||||
* @param config - raw compact-basic configuration.
|
* @param config - raw compact-basic configuration.
|
||||||
* @param tokenMeter - owning meter service used to reject unknown override names.
|
* @param tokenMeter - token meter supplying the context capacity.
|
||||||
* @returns a detached deeply immutable top-level configuration.
|
* @returns a detached deeply immutable configuration.
|
||||||
*/
|
*/
|
||||||
export function resolveConfig(
|
export function resolveConfig(
|
||||||
config: BasicCompactConfig = {},
|
config: BasicCompactConfig = {},
|
||||||
tokenMeter: TokenMeterService,
|
tokenMeter: TokenMeterService,
|
||||||
): ResolvedConfig {
|
): ResolvedConfig {
|
||||||
const configuredModels: unknown = config.models
|
validateConfigKeys(config)
|
||||||
const models = configuredModels === undefined ? {} : configuredModels
|
const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
|
||||||
if (typeof models !== 'object' || models === null || Array.isArray(models)) {
|
const retainTokens = config.retainTokens
|
||||||
throw new Error('BasicCompactConfig: models must be an object')
|
?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO)
|
||||||
}
|
|
||||||
|
|
||||||
const detachedModels: Record<string, ModelCompactConfig> = {}
|
|
||||||
for (const [model, override] of Object.entries(models as Record<string, unknown>)) {
|
|
||||||
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,
|
|
||||||
maxOverflowRetries: 1,
|
|
||||||
auto: true,
|
|
||||||
}, meter)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved: ResolvedConfig = {
|
const resolved: ResolvedConfig = {
|
||||||
models: detachedModels,
|
thresholdRatio,
|
||||||
|
retainTokens,
|
||||||
summarizationModel: config.summarizationModel ?? '',
|
summarizationModel: config.summarizationModel ?? '',
|
||||||
maxTokens: config.maxTokens ?? 8192,
|
maxTokens: config.maxTokens ?? 8192,
|
||||||
compactionRetries: config.compactionRetries ?? 1,
|
compactionRetries: config.compactionRetries ?? 1,
|
||||||
maxOverflowRetries: config.maxOverflowRetries ?? 1,
|
maxOverflowRetries: config.maxOverflowRetries ?? 1,
|
||||||
auto: config.auto ?? true,
|
auto: config.auto ?? true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||||
|
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||||
|
const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio)
|
||||||
|
if (resolved.retainTokens >= thresholdTokens) {
|
||||||
|
throw new Error(
|
||||||
|
`BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||||
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
|
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
|
||||||
@@ -69,36 +79,7 @@ export function resolveConfig(
|
|||||||
if (typeof resolved.auto !== 'boolean') {
|
if (typeof resolved.auto !== 'boolean') {
|
||||||
throw new Error('BasicCompactConfig: auto must be a boolean')
|
throw new Error('BasicCompactConfig: auto must be a boolean')
|
||||||
}
|
}
|
||||||
return deepFreeze(structuredClone(resolved))
|
return deepFreeze(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 {
|
function assertPositiveInteger(name: string, value: number): void {
|
||||||
|
|||||||
@@ -11,34 +11,21 @@ import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compa
|
|||||||
import type { Session } from '@deepseek-ai/dsh-session'
|
import type { Session } from '@deepseek-ai/dsh-session'
|
||||||
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import {
|
|
||||||
TOKEN_METER_MODEL_UNCONFIGURED,
|
|
||||||
TokenMeterError,
|
|
||||||
} from '@deepseek-ai/dsh-token-meter'
|
|
||||||
import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter'
|
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { resolveConfig, resolveModelConfig } from './config.ts'
|
import { resolveConfig } from './config.ts'
|
||||||
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
|
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
|
||||||
import { summarizeWithLlm } from './summarizer.ts'
|
import { summarizeWithLlm } from './summarizer.ts'
|
||||||
import type {
|
import type {
|
||||||
BasicCompactConfig,
|
BasicCompactConfig,
|
||||||
ResolvedConfig,
|
ResolvedConfig,
|
||||||
ResolvedModelCompactConfig,
|
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
|
||||||
export { resolveConfig, resolveModelConfig } from './config.ts'
|
export { resolveConfig } from './config.ts'
|
||||||
export type {
|
export type {
|
||||||
BasicCompactConfig,
|
BasicCompactConfig,
|
||||||
ModelCompactConfig,
|
|
||||||
ResolvedConfig,
|
ResolvedConfig,
|
||||||
ResolvedModelCompactConfig,
|
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve the exact model durably routed for the latest provider request. */
|
/** Resolve the exact model durably routed for the latest provider request. */
|
||||||
function routedModel(session: Session): string | undefined {
|
function routedModel(session: Session): string | undefined {
|
||||||
const model = session.requestHeader()?.config.model
|
const model = session.requestHeader()?.config.model
|
||||||
@@ -50,17 +37,15 @@ function routedModel(session: Session): string | undefined {
|
|||||||
* retention, provenance, and summary-convergence pricing.
|
* retention, provenance, and summary-convergence pricing.
|
||||||
*
|
*
|
||||||
* `summarize()` is the sole subclass customization hook; the replay and durable
|
* `summarize()` is the sole subclass customization hook; the replay and durable
|
||||||
* mutation strategy stays fixed so every pricing decision uses one effective
|
* mutation strategy stays fixed so every pricing decision uses the singleton
|
||||||
* conversation-model meter.
|
* token meter.
|
||||||
*/
|
*/
|
||||||
export class BasicCompactService extends CompactService {
|
export class BasicCompactService extends CompactService {
|
||||||
static inject = ['llm', 'tokenMeter']
|
static inject = ['llm', 'tokenMeter']
|
||||||
|
|
||||||
static Config: z<BasicCompactConfig> = z.object({
|
static Config: z<BasicCompactConfig> = z.object({
|
||||||
models: z.dict(z.object({
|
thresholdRatio: z.number().default(0.8),
|
||||||
thresholdRatio: z.number(),
|
retainTokens: z.number().step(1),
|
||||||
retainTokens: z.number().step(1),
|
|
||||||
})),
|
|
||||||
summarizationModel: z.string().default(''),
|
summarizationModel: z.string().default(''),
|
||||||
maxTokens: z.number().step(1).min(1).default(8192),
|
maxTokens: z.number().step(1).min(1).default(8192),
|
||||||
compactionRetries: z.number().step(1).min(0).default(1),
|
compactionRetries: z.number().step(1).min(0).default(1),
|
||||||
@@ -68,11 +53,9 @@ export class BasicCompactService extends CompactService {
|
|||||||
auto: z.boolean().default(true),
|
auto: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Resolved and validated common configuration plus named partial overrides. */
|
/** Resolved and validated compaction configuration. */
|
||||||
readonly config: ResolvedConfig
|
readonly config: ResolvedConfig
|
||||||
|
|
||||||
private readonly modelConfigs = new Map<string, ResolvedModelCompactConfig>()
|
|
||||||
|
|
||||||
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
||||||
super(ctx)
|
super(ctx)
|
||||||
this.config = resolveConfig(config, ctx.tokenMeter)
|
this.config = resolveConfig(config, ctx.tokenMeter)
|
||||||
@@ -105,10 +88,6 @@ export class BasicCompactService extends CompactService {
|
|||||||
const result = await this.compactIfNeeded(agent, 'pressure', signal)
|
const result = await this.compactIfNeeded(agent, 'pressure', signal)
|
||||||
if (result !== null) logResult(result, 'post-step pressure')
|
if (result !== null) logResult(result, 'post-step pressure')
|
||||||
} catch (error: unknown) {
|
} 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)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
|
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
|
||||||
}
|
}
|
||||||
@@ -157,7 +136,7 @@ export class BasicCompactService extends CompactService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact for replayed post-step pressure or one provider-confirmed context
|
* Compact for replayed post-step pressure or one provider-confirmed context
|
||||||
* overflow. Both triggers price the latest durable routed request model;
|
* overflow. Both triggers price the latest durable routed request envelope;
|
||||||
* overflow bypasses the normal threshold and retained-tail policy so it can
|
* overflow bypasses the normal threshold and retained-tail policy so it can
|
||||||
* force one useful balanced reduction.
|
* force one useful balanced reduction.
|
||||||
* @param agent - agent whose latest durable routed request is measured.
|
* @param agent - agent whose latest durable routed request is measured.
|
||||||
@@ -172,28 +151,21 @@ export class BasicCompactService extends CompactService {
|
|||||||
): Promise<CompactionResult | null> {
|
): Promise<CompactionResult | null> {
|
||||||
const model = routedModel(agent.session)
|
const model = routedModel(agent.session)
|
||||||
if (model === undefined) return null
|
if (model === undefined) return null
|
||||||
const meter = this.ctx.tokenMeter.resolve(model)
|
const meter = this.ctx.tokenMeter
|
||||||
const policy = this._modelConfig(meter)
|
|
||||||
if (trigger === 'context-overflow') {
|
if (trigger === 'context-overflow') {
|
||||||
const surface = meter.measureSurface(agent.session)
|
const measurement = meter.measure(agent.session)
|
||||||
const range = selectCompactableRange(agent.session, surface, 0)
|
const range = selectCompactableRange(agent.session, measurement, 0)
|
||||||
if (range === null) return null
|
if (range === null) return null
|
||||||
return this.compactRegion(agent.session, range.start, range.end, agent, signal)
|
return this.compactRegion(agent.session, range.start, range.end, agent, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
const threshold = Math.floor(policy.contextWindow * policy.thresholdRatio)
|
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
|
||||||
let measurement = meter.measure(agent.session)
|
let measurement = meter.measure(agent.session)
|
||||||
if (measurement.totalTokens < threshold) return null
|
if (measurement.totalTokens < threshold) return null
|
||||||
|
|
||||||
let result: CompactionResult | null = null
|
let result: CompactionResult | null = null
|
||||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
|
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
|
||||||
const surface = meter.measureSurface(agent.session)
|
const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens)
|
||||||
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) {
|
if (range === null) {
|
||||||
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
|
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
|
||||||
if (result === null) return null
|
if (result === null) return null
|
||||||
@@ -213,12 +185,12 @@ export class BasicCompactService extends CompactService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact one inclusive positional surface range using the effective
|
* Compact one inclusive positional surface range using the effective
|
||||||
* conversation model for all retention and shrink pricing. Reject an agent
|
* token meter for all retention and shrink pricing. Reject an agent that does
|
||||||
* that does not own the exact target before any resolution or mutation.
|
* not own the exact target before any mutation.
|
||||||
* @param session - session whose surface is mutated; must equal `agent.session`.
|
* @param session - session whose surface is mutated; must equal `agent.session`.
|
||||||
* @param start - inclusive first surface-node seq.
|
* @param start - inclusive first surface-node seq.
|
||||||
* @param end - inclusive last surface-node seq.
|
* @param end - inclusive last surface-node seq.
|
||||||
* @param agent - owner of the target session, used by the summarizer and model resolver.
|
* @param agent - owner of the target session, used by the summarizer.
|
||||||
* @param signal - optional summarization cancellation signal.
|
* @param signal - optional summarization cancellation signal.
|
||||||
* @returns the successful durable compaction result.
|
* @returns the successful durable compaction result.
|
||||||
*/
|
*/
|
||||||
@@ -232,27 +204,11 @@ export class BasicCompactService extends CompactService {
|
|||||||
if (session !== agent.session) {
|
if (session !== agent.session) {
|
||||||
throw new Error('compactRegion: agent.session must be the exact target session')
|
throw new Error('compactRegion: agent.session must be the exact target session')
|
||||||
}
|
}
|
||||||
const model = effectiveModel(agent)
|
|
||||||
if (model === undefined || model.length === 0) {
|
|
||||||
throw new Error('compactRegion: no routed or configured conversation model is available for token pricing')
|
|
||||||
}
|
|
||||||
const meter = this.ctx.tokenMeter.resolve(model)
|
|
||||||
this._modelConfig(meter)
|
|
||||||
return compactSurfaceRegion({
|
return compactSurfaceRegion({
|
||||||
meter,
|
meter: this.ctx.tokenMeter,
|
||||||
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
||||||
}, session, start, end, agent, signal)
|
}, session, start, end, agent, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve and memoize one lazy default/override model policy. */
|
|
||||||
private _modelConfig(meter: ModelTokenMeter): ResolvedModelCompactConfig {
|
|
||||||
let modelConfig = this.modelConfigs.get(meter.model)
|
|
||||||
if (modelConfig === undefined) {
|
|
||||||
modelConfig = resolveModelConfig(this.config, meter)
|
|
||||||
this.modelConfigs.set(meter.model, modelConfig)
|
|
||||||
}
|
|
||||||
return modelConfig
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BasicCompactService
|
export default BasicCompactService
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
toolPairingBalancedBefore,
|
toolPairingBalancedBefore,
|
||||||
} from '@deepseek-ai/dsh-compact'
|
} from '@deepseek-ai/dsh-compact'
|
||||||
import type { CompactionResult } 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 { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { frameSummary } from './summarizer.ts'
|
import { frameSummary } from './summarizer.ts'
|
||||||
import type { SummaryResult } from './summarizer.ts'
|
import type { SummaryResult } from './summarizer.ts'
|
||||||
|
|
||||||
interface RegionDependencies {
|
interface RegionDependencies {
|
||||||
readonly meter: ModelTokenMeter
|
readonly meter: TokenMeterService
|
||||||
summarize(text: string, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
|
summarize(text: string, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,16 +25,16 @@ interface RegionDependencies {
|
|||||||
* Resolve the next head-anchored range while retaining a priced recent tail
|
* Resolve the next head-anchored range while retaining a priced recent tail
|
||||||
* and never splitting an assistant tool-call/result pair.
|
* and never splitting an assistant tool-call/result pair.
|
||||||
* @param session - session supplying authoritative current surface positions.
|
* @param session - session supplying authoritative current surface positions.
|
||||||
* @param pricedSurface - same-revision surface measurement from the conversation meter.
|
* @param measurement - unified pressure and surface measurement from the conversation meter.
|
||||||
* @param retainTokens - minimum recent tail budget retained verbatim.
|
* @param retainTokens - minimum recent tail budget retained verbatim.
|
||||||
* @returns the inclusive positional seq range to compact, or `null`.
|
* @returns the inclusive positional seq range to compact, or `null`.
|
||||||
*/
|
*/
|
||||||
export function selectCompactableRange(
|
export function selectCompactableRange(
|
||||||
session: Session,
|
session: Session,
|
||||||
pricedSurface: TokenSurfaceMeasurement,
|
measurement: TokenMeasurement,
|
||||||
retainTokens: number,
|
retainTokens: number,
|
||||||
): { start: number; end: number } | null {
|
): { start: number; end: number } | null {
|
||||||
const pricedNodes = pricedSurface.nodes
|
const pricedNodes = measurement.nodes
|
||||||
if (pricedNodes.length === 0) return null
|
if (pricedNodes.length === 0) return null
|
||||||
|
|
||||||
const surfaceNodes = session.surface.nodes
|
const surfaceNodes = session.surface.nodes
|
||||||
@@ -115,8 +115,8 @@ export async function compactSurfaceRegion(
|
|||||||
try {
|
try {
|
||||||
// Capture after the lock event so any later durable append, including a
|
// Capture after the lock event so any later durable append, including a
|
||||||
// log-only one, invalidates the async selection before replacement.
|
// log-only one, invalidates the async selection before replacement.
|
||||||
const lockedSurface = dependencies.meter.measureSurface(session)
|
const lockedMeasurement = dependencies.meter.measure(session)
|
||||||
const selected = lockedSurface.nodes.slice(startIdx, endIdx + 1)
|
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
|
||||||
if (selected.length !== shadowedSeqs.length
|
if (selected.length !== shadowedSeqs.length
|
||||||
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
|
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
|
||||||
throw new Error('compaction: selected surface changed before summarization began')
|
throw new Error('compaction: selected surface changed before summarization began')
|
||||||
@@ -125,8 +125,8 @@ export async function compactSurfaceRegion(
|
|||||||
const text = renderTranscript(session.events, shadowedSeqs)
|
const text = renderTranscript(session.events, shadowedSeqs)
|
||||||
const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal)
|
const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal)
|
||||||
|
|
||||||
const currentSurface = dependencies.meter.measureSurface(session)
|
const currentMeasurement = dependencies.meter.measure(session)
|
||||||
if (currentSurface.logRevision !== lockedSurface.logRevision) {
|
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
|
||||||
throw new Error('compaction: session log changed during summarization')
|
throw new Error('compaction: session log changed during summarization')
|
||||||
}
|
}
|
||||||
const framedSummary = frameSummary(summary)
|
const framedSummary = frameSummary(summary)
|
||||||
|
|||||||
@@ -4,18 +4,12 @@
|
|||||||
* @module @deepseek-ai/dsh-compact-basic/types
|
* @module @deepseek-ai/dsh-compact-basic/types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Optional pressure and retention policy for one metered model. */
|
/** Basic compaction configuration; every common field has a deployment default. */
|
||||||
export interface ModelCompactConfig {
|
export interface BasicCompactConfig {
|
||||||
/** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */
|
/** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */
|
||||||
thresholdRatio?: number
|
thresholdRatio?: number
|
||||||
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
|
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
|
||||||
retainTokens?: number
|
retainTokens?: number
|
||||||
}
|
|
||||||
|
|
||||||
/** Basic compaction configuration; every common field has a deployment default. */
|
|
||||||
export interface BasicCompactConfig {
|
|
||||||
/** Field-wise pressure/retention overrides keyed by configured token-meter model name. */
|
|
||||||
models?: Record<string, ModelCompactConfig>
|
|
||||||
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
|
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
|
||||||
summarizationModel?: string
|
summarizationModel?: string
|
||||||
/** Provider generation cap for summarization. Defaults to `8192`. */
|
/** Provider generation cap for summarization. Defaults to `8192`. */
|
||||||
@@ -28,20 +22,13 @@ export interface BasicCompactConfig {
|
|||||||
auto?: boolean
|
auto?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validated top-level defaults plus detached per-model partial overrides. */
|
/** Validated and detached compaction configuration. */
|
||||||
export interface ResolvedConfig {
|
export interface ResolvedConfig {
|
||||||
readonly models: Readonly<Record<string, Readonly<ModelCompactConfig>>>
|
readonly thresholdRatio: number
|
||||||
|
readonly retainTokens: number
|
||||||
readonly summarizationModel: string
|
readonly summarizationModel: string
|
||||||
readonly maxTokens: number
|
readonly maxTokens: number
|
||||||
readonly compactionRetries: number
|
readonly compactionRetries: number
|
||||||
readonly maxOverflowRetries: number
|
readonly maxOverflowRetries: number
|
||||||
readonly auto: boolean
|
readonly auto: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fully resolved pressure/retention policy for one effective model. */
|
|
||||||
export interface ResolvedModelCompactConfig {
|
|
||||||
readonly model: string
|
|
||||||
readonly contextWindow: number
|
|
||||||
readonly thresholdRatio: number
|
|
||||||
readonly retainTokens: number
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import BasicCompactService, {
|
import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||||
resolveConfig,
|
|
||||||
resolveModelConfig,
|
|
||||||
} from '@deepseek-ai/dsh-compact-basic'
|
|
||||||
import type { BasicCompactConfig } 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 { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
|
||||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||||
@@ -11,22 +8,15 @@ import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
|||||||
import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import TokenMeterService, {
|
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||||
TOKEN_METER_MODEL_UNCONFIGURED,
|
|
||||||
TokenMeterError,
|
|
||||||
} from '@deepseek-ai/dsh-token-meter'
|
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
|
|
||||||
const SIGNAL = new AbortController().signal
|
const SIGNAL = new AbortController().signal
|
||||||
const MODEL = 'test-model'
|
const MODEL = 'test-model'
|
||||||
|
|
||||||
function createContext(
|
function createContext(contextWindow = 1_000): Context {
|
||||||
models: Record<string, { contextWindow?: number; charsPerToken?: number }> = {
|
|
||||||
[MODEL]: { contextWindow: 100, charsPerToken: 1_000 },
|
|
||||||
},
|
|
||||||
): Context {
|
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
void new TokenMeterService(ctx, { models })
|
void new TokenMeterService(ctx, { contextWindow })
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +25,7 @@ function agent(session: Session, model?: string): Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Closed two-message turns followed by one open turn for durable compaction events. */
|
/** Closed two-message turns followed by one open turn for durable compaction events. */
|
||||||
function conversation(turns = 4, text = 'fixture'): Session {
|
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||||
const session = new Session(SessionId(`conversation-${turns}`))
|
const session = new Session(SessionId(`conversation-${turns}`))
|
||||||
for (let turn = 1; turn <= turns; turn += 1) {
|
for (let turn = 1; turn <= turns; turn += 1) {
|
||||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||||
@@ -140,51 +130,42 @@ async function compactIfNeeded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('compact configuration and defaults', () => {
|
describe('compact configuration and defaults', () => {
|
||||||
it('uses low-friction common and per-profile defaults', () => {
|
it('uses low-friction service-wide defaults', () => {
|
||||||
const ctx = createContext({
|
const ctx = createContext()
|
||||||
[MODEL]: { contextWindow: 100, charsPerToken: 1_000 },
|
|
||||||
large: { contextWindow: 1_000, charsPerToken: 4 },
|
|
||||||
})
|
|
||||||
const resolved = resolveConfig({}, ctx.tokenMeter)
|
const resolved = resolveConfig({}, ctx.tokenMeter)
|
||||||
|
|
||||||
expect(resolved).toEqual({
|
expect(resolved).toEqual({
|
||||||
models: {},
|
thresholdRatio: 0.8,
|
||||||
|
retainTokens: 160,
|
||||||
summarizationModel: '',
|
summarizationModel: '',
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
compactionRetries: 1,
|
compactionRetries: 1,
|
||||||
maxOverflowRetries: 1,
|
maxOverflowRetries: 1,
|
||||||
auto: true,
|
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)
|
expect(Object.isFrozen(resolved)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('merges threshold and retention overrides field-wise', () => {
|
it('resolves threshold and retention overrides independently', () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
const thresholdOnly = resolveConfig({
|
const thresholdOnly = resolveConfig({
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5 } },
|
|
||||||
}, ctx.tokenMeter)
|
|
||||||
expect(resolveModelConfig(thresholdOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({
|
|
||||||
thresholdRatio: 0.5,
|
thresholdRatio: 0.5,
|
||||||
retainTokens: 16,
|
}, ctx.tokenMeter)
|
||||||
|
expect(thresholdOnly).toMatchObject({
|
||||||
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 160,
|
||||||
})
|
})
|
||||||
|
|
||||||
const retentionOnly = resolveConfig({
|
const retentionOnly = resolveConfig({
|
||||||
models: { [MODEL]: { retainTokens: 7 } },
|
retainTokens: 70,
|
||||||
}, ctx.tokenMeter)
|
}, ctx.tokenMeter)
|
||||||
expect(resolveModelConfig(retentionOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({
|
expect(retentionOnly).toMatchObject({
|
||||||
thresholdRatio: 0.8,
|
thresholdRatio: 0.8,
|
||||||
retainTokens: 7,
|
retainTokens: 70,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('validates common values and model policy invariants', () => {
|
it('validates common values and pressure-policy invariants', () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
const bad = [
|
const bad = [
|
||||||
[{ maxTokens: 0 }, /maxTokens/],
|
[{ maxTokens: 0 }, /maxTokens/],
|
||||||
@@ -192,33 +173,25 @@ describe('compact configuration and defaults', () => {
|
|||||||
[{ maxOverflowRetries: -1 }, /maxOverflowRetries/],
|
[{ maxOverflowRetries: -1 }, /maxOverflowRetries/],
|
||||||
[{ auto: 'yes' }, /auto must be a boolean/],
|
[{ auto: 'yes' }, /auto must be a boolean/],
|
||||||
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
|
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
|
||||||
[{ models: null }, /models must be an object/],
|
[{ thresholdRatio: 0 }, /number in \(0, 1\]/],
|
||||||
[{ models: { [MODEL]: null } }, /must be an object/],
|
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
|
||||||
[{ models: { [MODEL]: { thresholdRatio: 0 } } }, /number in \(0, 1\]/],
|
[{ retainTokens: -1 }, /non-negative integer/],
|
||||||
[{ models: { [MODEL]: { thresholdRatio: 1.1 } } }, /number in \(0, 1\]/],
|
[{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/],
|
||||||
[{ models: { [MODEL]: { retainTokens: -1 } } }, /non-negative integer/],
|
[{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/],
|
||||||
[{ models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 50 } } }, /less than threshold/],
|
[{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/],
|
||||||
] as Array<[unknown, RegExp]>
|
] as Array<[unknown, RegExp]>
|
||||||
|
|
||||||
for (const [config, pattern] of bad) {
|
for (const [config, pattern] of bad) {
|
||||||
expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern)
|
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', () => {
|
describe('pressure measurement and retention', () => {
|
||||||
const compactConfig: BasicCompactConfig = {
|
const compactConfig: BasicCompactConfig = {
|
||||||
auto: false,
|
auto: false,
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 180,
|
||||||
}
|
}
|
||||||
|
|
||||||
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
|
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
|
||||||
@@ -230,15 +203,15 @@ describe('pressure measurement and retention', () => {
|
|||||||
expect(compact.calls).toHaveLength(0)
|
expect(compact.calls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws for a named unconfigured model instead of swallowing it', async () => {
|
it('meters any routed model without profile resolution', async () => {
|
||||||
const compact = service(compactConfig)
|
const compact = service(compactConfig)
|
||||||
const session = conversation()
|
const session = conversation()
|
||||||
session.append('request/header', {
|
session.append('request/header', {
|
||||||
header: { config: { model: 'missing' } },
|
header: { config: { model: 'unlisted-model' } },
|
||||||
reason: 'resume',
|
reason: 'resume',
|
||||||
})
|
})
|
||||||
await expect(compactIfNeeded(compact, session))
|
await expect(compactIfNeeded(compact, session))
|
||||||
.rejects.toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED, model: 'missing' })
|
.resolves.not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
|
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
|
||||||
@@ -286,16 +259,17 @@ describe('pressure measurement and retention', () => {
|
|||||||
it('counts the durable routed request envelope without putting its prefix on the surface', async () => {
|
it('counts the durable routed request envelope without putting its prefix on the surface', async () => {
|
||||||
const compact = service({
|
const compact = service({
|
||||||
auto: false,
|
auto: false,
|
||||||
models: { [MODEL]: { thresholdRatio: 0.7, retainTokens: 9 } },
|
thresholdRatio: 0.9,
|
||||||
|
retainTokens: 50,
|
||||||
})
|
})
|
||||||
const session = conversation(2, 'x'.repeat(2_000))
|
const session = conversation(2, 'x'.repeat(600))
|
||||||
expect(await compactIfNeeded(compact, session)).toBeNull()
|
expect(await compactIfNeeded(compact, session)).toBeNull()
|
||||||
|
|
||||||
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(10_000) }] }]
|
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }]
|
||||||
session.append('request/header', {
|
session.append('request/header', {
|
||||||
header: {
|
header: {
|
||||||
config: { model: MODEL },
|
config: { model: MODEL },
|
||||||
system: 's'.repeat(5_000),
|
system: 's'.repeat(600),
|
||||||
messagePrefix: prefix,
|
messagePrefix: prefix,
|
||||||
},
|
},
|
||||||
reason: 'resume',
|
reason: 'resume',
|
||||||
@@ -306,23 +280,24 @@ describe('pressure measurement and retention', () => {
|
|||||||
expect(session.events.some(event => event.type === 'context/message')).toBe(false)
|
expect(session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the latest logged routed model instead of AgentOptions.model', async () => {
|
it('uses the latest logged request envelope without an AgentOptions override', async () => {
|
||||||
const ctx = createContext({
|
const ctx = createContext()
|
||||||
actual: { contextWindow: 100, charsPerToken: 1_000 },
|
|
||||||
fallback: { contextWindow: 10_000, charsPerToken: 1_000 },
|
|
||||||
})
|
|
||||||
const compact = service({
|
const compact = service({
|
||||||
auto: false,
|
auto: false,
|
||||||
models: { actual: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 180,
|
||||||
}, ctx)
|
}, ctx)
|
||||||
const session = conversation(4)
|
const session = conversation(4)
|
||||||
session.append('request/header', {
|
session.append('request/header', {
|
||||||
header: { config: { model: 'actual' } },
|
header: { config: { model: 'actual' } },
|
||||||
reason: 'initial',
|
reason: 'initial',
|
||||||
})
|
})
|
||||||
|
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
|
||||||
|
|
||||||
const result = await compactIfNeeded(compact, session, 'pressure', 'fallback')
|
const result = await compactIfNeeded(compact, session, 'pressure', 'fallback')
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
|
expect(session.requestHeader()?.config.model).toBe('actual')
|
||||||
|
expect(measure.mock.calls[0]).toEqual([session])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
|
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
|
||||||
@@ -343,24 +318,23 @@ describe('pressure measurement and retention', () => {
|
|||||||
expect(await compactIfNeeded(compact, retained)).toBeNull()
|
expect(await compactIfNeeded(compact, retained)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('detects scalar/surface revision disagreement', async () => {
|
it('uses one unified measurement for each pressure-and-retention decision', async () => {
|
||||||
const ctx = createContext()
|
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)
|
const compact = service(compactConfig, ctx)
|
||||||
|
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
|
||||||
|
const stop = new Error('stop after first decision')
|
||||||
|
vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop)
|
||||||
|
|
||||||
await expect(compactIfNeeded(compact, conversation(4))).rejects.toThrow(/revision/)
|
await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop)
|
||||||
|
expect(measure).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
|
it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
|
||||||
const compact = service({
|
const compact = service({
|
||||||
auto: false,
|
auto: false,
|
||||||
compactionRetries: 0,
|
compactionRetries: 0,
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.3,
|
||||||
|
retainTokens: 180,
|
||||||
})
|
})
|
||||||
compact.summary = Array.from({ length: 7 }, (_, index) => ({
|
compact.summary = Array.from({ length: 7 }, (_, index) => ({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -374,8 +348,9 @@ describe('pressure measurement and retention', () => {
|
|||||||
it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => {
|
it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => {
|
||||||
const compact = service({
|
const compact = service({
|
||||||
auto: false,
|
auto: false,
|
||||||
models: { [MODEL]: { thresholdRatio: 0.8, retainTokens: 8 } },
|
thresholdRatio: 0.8,
|
||||||
})
|
retainTokens: 80,
|
||||||
|
}, createContext(4_000))
|
||||||
const session = toolConversation()
|
const session = toolConversation()
|
||||||
const result = await compactIfNeeded(compact, session)
|
const result = await compactIfNeeded(compact, session)
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
@@ -393,7 +368,7 @@ describe('pressure measurement and retention', () => {
|
|||||||
it('rejects a priced surface that is not the current positional surface', () => {
|
it('rejects a priced surface that is not the current positional surface', () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
const session = conversation(2)
|
const session = conversation(2)
|
||||||
const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session)
|
const priced = ctx.tokenMeter.measure(session)
|
||||||
expect(() => selectCompactableRange(session, {
|
expect(() => selectCompactableRange(session, {
|
||||||
...priced,
|
...priced,
|
||||||
nodes: priced.nodes.slice(1),
|
nodes: priced.nodes.slice(1),
|
||||||
@@ -421,7 +396,7 @@ describe('pressure measurement and retention', () => {
|
|||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
session.append('step/end', { turn: 1, step: 1 })
|
session.append('step/end', { turn: 1, step: 1 })
|
||||||
|
|
||||||
const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session)
|
const priced = ctx.tokenMeter.measure(session)
|
||||||
expect(selectCompactableRange(session, priced, 1)).toBeNull()
|
expect(selectCompactableRange(session, priced, 1)).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -563,9 +538,9 @@ describe('compaction region transaction', () => {
|
|||||||
|
|
||||||
it('rejects a meter snapshot that changed before summarization began', async () => {
|
it('rejects a meter snapshot that changed before summarization began', async () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
const meter = ctx.tokenMeter.resolve(MODEL)
|
const meter = ctx.tokenMeter
|
||||||
const original = meter.measureSurface.bind(meter)
|
const original = meter.measure.bind(meter)
|
||||||
vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => {
|
vi.spyOn(meter, 'measure').mockImplementationOnce((session) => {
|
||||||
const measurement = original(session)
|
const measurement = original(session)
|
||||||
return { ...measurement, nodes: measurement.nodes.slice(1) }
|
return { ...measurement, nodes: measurement.nodes.slice(1) }
|
||||||
})
|
})
|
||||||
@@ -635,7 +610,7 @@ describe('compaction region transaction', () => {
|
|||||||
|
|
||||||
it('rejects a non-shrinking framed summary under the conversation meter', async () => {
|
it('rejects a non-shrinking framed summary under the conversation meter', async () => {
|
||||||
const compact = service()
|
const compact = service()
|
||||||
compact.summary = Array.from({ length: 20 }, (_, index) => ({
|
compact.summary = Array.from({ length: 100 }, (_, index) => ({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `verbose ${index}`,
|
text: `verbose ${index}`,
|
||||||
}))
|
}))
|
||||||
@@ -651,19 +626,19 @@ describe('compaction region transaction', () => {
|
|||||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires a conversation model for pricing', async () => {
|
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
|
||||||
const compact = service()
|
const compact = service()
|
||||||
const session = new Session(SessionId('model-less-region'))
|
const session = new Session(SessionId('model-less-region'))
|
||||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||||
session.append('user/message', {
|
session.append('user/message', {
|
||||||
content: [{ type: 'text', text: 'history' }],
|
content: [{ type: 'text', text: 'history '.repeat(100) }],
|
||||||
source: { kind: 'user' },
|
source: { kind: 'user' },
|
||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
session.append('step/start', { turn: 1, step: 1 })
|
session.append('step/start', { turn: 1, step: 1 })
|
||||||
session.append('assistant/message', {
|
session.append('assistant/message', {
|
||||||
turn: 1,
|
turn: 1,
|
||||||
step: 1,
|
step: 1,
|
||||||
content: [{ type: 'text', text: 'answer' }],
|
content: [{ type: 'text', text: 'answer '.repeat(100) }],
|
||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
session.append('step/end', { turn: 1, step: 1 })
|
session.append('step/end', { turn: 1, step: 1 })
|
||||||
const nodes = session.surface.nodes
|
const nodes = session.surface.nodes
|
||||||
@@ -672,7 +647,7 @@ describe('compaction region transaction', () => {
|
|||||||
nodes[0]!.seq,
|
nodes[0]!.seq,
|
||||||
nodes[1]!.seq,
|
nodes[1]!.seq,
|
||||||
agent(session),
|
agent(session),
|
||||||
)).rejects.toThrow(/no routed or configured conversation model/)
|
)).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -710,7 +685,7 @@ async function summarizerHarness(
|
|||||||
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
|
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
void new TokenMeterService(ctx, { models: { [model]: { contextWindow: 100 } } })
|
void new TokenMeterService(ctx, { contextWindow: 1_000 })
|
||||||
const adapter = new ScriptedAdapter(blocks, finish)
|
const adapter = new ScriptedAdapter(blocks, finish)
|
||||||
ctx.llm.registerAdapter([model], adapter)
|
ctx.llm.registerAdapter([model], adapter)
|
||||||
const compact = new BasicCompactService(ctx, config)
|
const compact = new BasicCompactService(ctx, config)
|
||||||
@@ -836,7 +811,8 @@ describe('automatic listener and loader composition', () => {
|
|||||||
it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => {
|
it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
const compact = new TestCompactService(ctx, {
|
const compact = new TestCompactService(ctx, {
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 180,
|
||||||
})
|
})
|
||||||
const pressured = conversation(4)
|
const pressured = conversation(4)
|
||||||
await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
|
await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
|
||||||
@@ -851,7 +827,8 @@ describe('automatic listener and loader composition', () => {
|
|||||||
it('skips post-step pressure when the step signal is already aborted', async () => {
|
it('skips post-step pressure when the step signal is already aborted', async () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
const compact = new TestCompactService(ctx, {
|
const compact = new TestCompactService(ctx, {
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 180,
|
||||||
})
|
})
|
||||||
const pressured = conversation(4)
|
const pressured = conversation(4)
|
||||||
const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
|
const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
|
||||||
@@ -868,7 +845,8 @@ describe('automatic listener and loader composition', () => {
|
|||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
|
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
|
||||||
const compact = new TestCompactService(ctx, {
|
const compact = new TestCompactService(ctx, {
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 180,
|
||||||
})
|
})
|
||||||
compact.error = 'temporary failure'
|
compact.error = 'temporary failure'
|
||||||
const session = conversation(4)
|
const session = conversation(4)
|
||||||
@@ -878,30 +856,17 @@ describe('automatic listener and loader composition', () => {
|
|||||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
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)
|
|
||||||
const session = conversation(4)
|
|
||||||
session.append('request/header', {
|
|
||||||
header: { config: { model: 'missing' } },
|
|
||||||
reason: 'resume',
|
|
||||||
})
|
|
||||||
await expect(postStep(ctx, agent(session, MODEL))).rejects.toMatchObject({
|
|
||||||
code: TOKEN_METER_MODEL_UNCONFIGURED,
|
|
||||||
model: 'missing',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => {
|
it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext(10_000)
|
||||||
void new TestCompactService(ctx, {
|
void new TestCompactService(ctx, {
|
||||||
models: { [MODEL]: { thresholdRatio: 1, retainTokens: 90 } },
|
thresholdRatio: 1,
|
||||||
|
retainTokens: 900,
|
||||||
})
|
})
|
||||||
const session = conversation(3)
|
const session = conversation(3)
|
||||||
const beforeGeneration = session.surface.replaceGeneration
|
const beforeGeneration = session.surface.replaceGeneration
|
||||||
const retainedSeq = session.surface.nodes.at(-1)!.seq
|
const retainedSeq = session.surface.nodes.at(-1)!.seq
|
||||||
const threshold = 100
|
const threshold = 10_000
|
||||||
expect(ctx.tokenMeter.resolve(MODEL).measure(session).totalTokens).toBeLessThan(threshold)
|
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
|
||||||
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
|
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
|
||||||
|
|
||||||
expect(decision).toEqual({ action: 'retry' })
|
expect(decision).toEqual({ action: 'retry' })
|
||||||
@@ -913,7 +878,8 @@ describe('automatic listener and loader composition', () => {
|
|||||||
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
|
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
void new TestCompactService(ctx, {
|
void new TestCompactService(ctx, {
|
||||||
models: { [MODEL]: { thresholdRatio: 1, retainTokens: 90 } },
|
thresholdRatio: 1,
|
||||||
|
retainTokens: 90,
|
||||||
})
|
})
|
||||||
const session = toolConversation()
|
const session = toolConversation()
|
||||||
const newestAssistant = session.surface.nodes.at(-2)!
|
const newestAssistant = session.surface.nodes.at(-2)!
|
||||||
@@ -1010,7 +976,7 @@ describe('automatic listener and loader composition', () => {
|
|||||||
expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure'))
|
expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('delegates once and preserves the original overflow for an unknown routed meter model', async () => {
|
it('recovers an overflow for an unlisted routed model', async () => {
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
void new TestCompactService(ctx)
|
void new TestCompactService(ctx)
|
||||||
const session = conversation(2)
|
const session = conversation(2)
|
||||||
@@ -1018,19 +984,8 @@ describe('automatic listener and loader composition', () => {
|
|||||||
header: { config: { model: 'unknown-routed-model' } },
|
header: { config: { model: 'unknown-routed-model' } },
|
||||||
reason: 'resume',
|
reason: 'resume',
|
||||||
})
|
})
|
||||||
const original = overflow('original unknown-model overflow')
|
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
|
||||||
let delegations = 0
|
.toEqual({ action: 'retry' })
|
||||||
|
|
||||||
const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
|
|
||||||
delegations += 1
|
|
||||||
return Promise.resolve({ action: 'fail' })
|
|
||||||
})
|
|
||||||
expect(decision).toEqual({ action: 'fail' })
|
|
||||||
expect(delegations).toBe(1)
|
|
||||||
expect(original).toMatchObject({
|
|
||||||
message: 'original unknown-model overflow',
|
|
||||||
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('honors retry caps, non-context failures, and cancellation', async () => {
|
it('honors retry caps, non-context failures, and cancellation', async () => {
|
||||||
@@ -1065,7 +1020,8 @@ describe('automatic listener and loader composition', () => {
|
|||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
void new TestCompactService(ctx, {
|
void new TestCompactService(ctx, {
|
||||||
maxOverflowRetries: 0,
|
maxOverflowRetries: 0,
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 180,
|
||||||
})
|
})
|
||||||
const session = conversation(4)
|
const session = conversation(4)
|
||||||
await postStep(ctx, agent(session, MODEL))
|
await postStep(ctx, agent(session, MODEL))
|
||||||
@@ -1079,7 +1035,8 @@ describe('automatic listener and loader composition', () => {
|
|||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
void new TestCompactService(ctx, {
|
void new TestCompactService(ctx, {
|
||||||
auto: false,
|
auto: false,
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 180,
|
||||||
})
|
})
|
||||||
const session = conversation(4)
|
const session = conversation(4)
|
||||||
await postStep(ctx, agent(session, MODEL))
|
await postStep(ctx, agent(session, MODEL))
|
||||||
@@ -1093,7 +1050,7 @@ describe('automatic listener and loader composition', () => {
|
|||||||
const meterFiber = await ctx.plugin(TokenMeterService)
|
const meterFiber = await ctx.plugin(TokenMeterService)
|
||||||
const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
|
const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
|
||||||
|
|
||||||
expect(ctx.tokenMeter.resolve('deepseek-v4-flash').contextWindow).toBe(128_000)
|
expect(ctx.tokenMeter.contextWindow).toBe(128_000)
|
||||||
expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
|
expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||||
await compactFiber.dispose()
|
await compactFiber.dispose()
|
||||||
expect(ctx.get('compact')).toBeUndefined()
|
expect(ctx.get('compact')).toBeUndefined()
|
||||||
@@ -1104,11 +1061,10 @@ describe('automatic listener and loader composition', () => {
|
|||||||
it('removes its automatic listener with the plugin fiber', async () => {
|
it('removes its automatic listener with the plugin fiber', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
await ctx.plugin(TokenMeterService, {
|
await ctx.plugin(TokenMeterService, { contextWindow: 1_000 })
|
||||||
models: { [MODEL]: { contextWindow: 100, charsPerToken: 1_000 } },
|
|
||||||
})
|
|
||||||
const fiber = await ctx.plugin(TestCompactService, {
|
const fiber = await ctx.plugin(TestCompactService, {
|
||||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 180,
|
||||||
})
|
})
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
|
|
||||||
@@ -1118,17 +1074,3 @@ describe('automatic listener and loader composition', () => {
|
|||||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
|
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -101,9 +101,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
|||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
await ctx.plugin(TokenMeterService, {
|
await ctx.plugin(TokenMeterService, { contextWindow: 400 })
|
||||||
models: { mock: { contextWindow: 64, charsPerToken: 1_000 } },
|
|
||||||
})
|
|
||||||
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
||||||
ctx.tools.register(defineTool({
|
ctx.tools.register(defineTool({
|
||||||
name: 'work',
|
name: 'work',
|
||||||
@@ -113,11 +111,12 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
|||||||
return [{ type: 'text', text: 'work result' }]
|
return [{ type: 'text', text: 'work result' }]
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
// Tiny window so a couple of tool steps cross the threshold and compaction
|
// Small window so several tool steps cross the threshold and compaction
|
||||||
// fires within the runaway turn.
|
// fires within the runaway turn after enough history can shrink.
|
||||||
const compact = new ReproCompactService(ctx, {
|
const compact = new ReproCompactService(ctx, {
|
||||||
auto: true,
|
auto: true,
|
||||||
models: { mock: { thresholdRatio: 0.5, retainTokens: 20 } },
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 50,
|
||||||
summarizationModel: '',
|
summarizationModel: '',
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
compactionRetries: 1,
|
compactionRetries: 1,
|
||||||
@@ -159,7 +158,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('runs automatic pressure after the current tool result and before step/end', async () => {
|
it('runs automatic pressure after the current tool result and before step/end', async () => {
|
||||||
const { ctx } = await harness(4)
|
const { ctx } = await harness(8)
|
||||||
try {
|
try {
|
||||||
const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' })
|
||||||
agent.send([{ type: 'text', text: 'do tool work' }])
|
agent.send([{ type: 'text', text: 'do tool work' }])
|
||||||
@@ -230,13 +229,12 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
|||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
await ctx.plugin(TokenMeterService, {
|
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
|
||||||
models: { mock: { contextWindow: 128, charsPerToken: 4 } },
|
|
||||||
})
|
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' }))
|
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' }))
|
||||||
await ctx.plugin(BasicCompactService, {
|
await ctx.plugin(BasicCompactService, {
|
||||||
models: { mock: { thresholdRatio: 1, retainTokens: 100 } },
|
thresholdRatio: 1,
|
||||||
|
retainTokens: 100,
|
||||||
maxTokens: 64,
|
maxTokens: 64,
|
||||||
compactionRetries: 0,
|
compactionRetries: 0,
|
||||||
maxOverflowRetries: 1,
|
maxOverflowRetries: 1,
|
||||||
|
|||||||
@@ -20,47 +20,75 @@ afterEach(async () => {
|
|||||||
root = undefined
|
root = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadYaml(lines: readonly string[]): Promise<Context> {
|
||||||
|
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
|
||||||
|
const configPath = join(root, 'cordis.yml')
|
||||||
|
await writeFile(configPath, [...lines, ''].join('\n'))
|
||||||
|
|
||||||
|
context = new Context()
|
||||||
|
context.baseUrl = pathToFileURL(root).href + '/'
|
||||||
|
await context.plugin(Loader)
|
||||||
|
context.loader.builtins.include = Include
|
||||||
|
const modules = new Map<string, unknown>([
|
||||||
|
['@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<typeof context.loader.internal>
|
||||||
|
await context.loader.create({
|
||||||
|
name: 'cordis:include',
|
||||||
|
config: { path: pathToFileURL(configPath).href },
|
||||||
|
})
|
||||||
|
await context.loader.await()
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
describe('real Loader composition', () => {
|
describe('real Loader composition', () => {
|
||||||
it('loads the zero-config token-meter then compact-basic YAML pair', async () => {
|
it('loads the flat token-meter and compact-basic YAML shape', async () => {
|
||||||
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
|
const loaded = await loadYaml([
|
||||||
const configPath = join(root, 'cordis.yml')
|
|
||||||
await writeFile(configPath, [
|
|
||||||
"- name: '@deepseek-ai/dsh-llm'",
|
"- name: '@deepseek-ai/dsh-llm'",
|
||||||
"- name: '@deepseek-ai/dsh-token-meter'",
|
"- name: '@deepseek-ai/dsh-token-meter'",
|
||||||
|
' config:',
|
||||||
|
' contextWindow: 4096',
|
||||||
"- name: '@deepseek-ai/dsh-compact-basic'",
|
"- name: '@deepseek-ai/dsh-compact-basic'",
|
||||||
'',
|
' config:',
|
||||||
].join('\n'))
|
' thresholdRatio: 0.5',
|
||||||
|
' retainTokens: 512',
|
||||||
context = new Context()
|
' auto: false',
|
||||||
context.baseUrl = pathToFileURL(root).href + '/'
|
|
||||||
await context.plugin(Loader)
|
|
||||||
context.loader.builtins.include = Include
|
|
||||||
const modules = new Map<string, unknown>([
|
|
||||||
['@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<typeof context.loader.internal>
|
|
||||||
await context.loader.create({
|
|
||||||
name: 'cordis:include',
|
|
||||||
config: { path: pathToFileURL(configPath).href },
|
|
||||||
})
|
|
||||||
await context.loader.await()
|
|
||||||
|
|
||||||
const unloaded = [...context.loader.entries()]
|
const unloaded = [...loaded.loader.entries()]
|
||||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||||
.map(entry => entry.options.name)
|
.map(entry => entry.options.name)
|
||||||
expect(unloaded).toEqual([])
|
expect(unloaded).toEqual([])
|
||||||
expect(context.tokenMeter.resolve('deepseek-v4-flash')).toMatchObject({
|
expect(loaded.tokenMeter.contextWindow).toBe(4096)
|
||||||
contextWindow: 128_000,
|
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||||
charsPerToken: 4,
|
expect((loaded.compact as BasicCompactService).config).toMatchObject({
|
||||||
|
thresholdRatio: 0.5,
|
||||||
|
retainTokens: 512,
|
||||||
|
auto: false,
|
||||||
})
|
})
|
||||||
expect(context.get('compact')).toBeInstanceOf(BasicCompactService)
|
})
|
||||||
|
|
||||||
|
it('rejects stale token-meter config after Schemastery normalization', async () => {
|
||||||
|
context = new Context()
|
||||||
|
await expect(context.plugin(TokenMeterService, {
|
||||||
|
models: { legacy: { contextWindow: 4096 } },
|
||||||
|
} as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects stale compact-basic config after Schemastery normalization', async () => {
|
||||||
|
context = new Context()
|
||||||
|
await context.plugin(LlmService)
|
||||||
|
await context.plugin(TokenMeterService)
|
||||||
|
await expect(context.plugin(BasicCompactService, {
|
||||||
|
models: { legacy: { thresholdRatio: 0.5 } },
|
||||||
|
} as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
|
|||||||
|
|
||||||
## Tool-pairing boundaries
|
## Tool-pairing boundaries
|
||||||
|
|
||||||
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates the node's seq against current surface membership and resolves the trailing edge from its cached positional successor, so a stale caller-held `node.next` cannot choose the cut.
|
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper identifies the node by seq alone and answers from balances cached per cut in current surface order, so a stale caller-held `node.next` cannot choose the cut.
|
||||||
|
|
||||||
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership, successors, and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
|
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
|
||||||
|
|
||||||
## Surface contract
|
## Surface contract
|
||||||
|
|
||||||
|
|||||||
@@ -12,19 +12,21 @@ import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-sessio
|
|||||||
interface BalanceCache {
|
interface BalanceCache {
|
||||||
/** Surface rewrite generation this state describes. */
|
/** Surface rewrite generation this state describes. */
|
||||||
generation: number
|
generation: number
|
||||||
/** Number of surface nodes already folded into the state. */
|
/**
|
||||||
processedNodes: number
|
* Balance of every surface cut in current order: a surface of N nodes has
|
||||||
/** Balance of the cut immediately before each current surface node. */
|
* N + 1 cuts, entry `i` being the cut before node `i` and the final entry
|
||||||
beforeSeq: Map<number, boolean>
|
* the cut after the surface tail.
|
||||||
/** Current positional successor of each surface node. */
|
*/
|
||||||
successorBySeq: Map<number, number | null>
|
cutBalanced: readonly boolean[]
|
||||||
/** Unanswered tool-call count after the processed surface tail. */
|
/** Current surface position of each node seq, indexing {@link cutBalanced}. */
|
||||||
depth: number
|
indexBySeq: Map<number, number>
|
||||||
|
/** In-progress tool-call count after the processed surface tail. */
|
||||||
|
inProgressToolCalls: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
|
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
|
||||||
|
|
||||||
/** Return how one surface event changes the unanswered tool-call count. */
|
/** Return how one surface event changes the in-progress tool-call count. */
|
||||||
function nodeDelta(event: SessionEvent): number {
|
function nodeDelta(event: SessionEvent): number {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'assistant/message':
|
case 'assistant/message':
|
||||||
@@ -45,61 +47,30 @@ function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): Sessi
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build balance state for a complete current surface. */
|
/** Fold surface nodes not yet in the cache into its balance state. */
|
||||||
function rebuildCache(
|
|
||||||
session: Session,
|
|
||||||
nodes: readonly SurfaceNode[],
|
|
||||||
generation: number,
|
|
||||||
): BalanceCache {
|
|
||||||
const beforeSeq = new Map<number, boolean>()
|
|
||||||
const successorBySeq = new Map<number, number | null>()
|
|
||||||
const events = session.events
|
|
||||||
let depth = 0
|
|
||||||
let previousSeq: number | undefined
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
beforeSeq.set(node.seq, depth === 0)
|
|
||||||
successorBySeq.set(node.seq, null)
|
|
||||||
if (previousSeq !== undefined) successorBySeq.set(previousSeq, node.seq)
|
|
||||||
depth += nodeDelta(eventForNode(events, node))
|
|
||||||
if (depth < 0) {
|
|
||||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
|
||||||
}
|
|
||||||
previousSeq = node.seq
|
|
||||||
}
|
|
||||||
|
|
||||||
return { generation, processedNodes: nodes.length, beforeSeq, successorBySeq, depth }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fold a pure surface tail append into existing balance state. */
|
|
||||||
function extendCache(
|
function extendCache(
|
||||||
session: Session,
|
session: Session,
|
||||||
cache: BalanceCache,
|
cache: BalanceCache,
|
||||||
nodes: readonly SurfaceNode[],
|
nodes: readonly SurfaceNode[],
|
||||||
): BalanceCache {
|
): BalanceCache {
|
||||||
const tail = nodes.slice(cache.processedNodes)
|
const processed = cache.cutBalanced.length - 1
|
||||||
|
const tail = nodes.slice(processed)
|
||||||
// Validate the unseen tail before mutating the live cache, so a corrupt
|
// Validate the unseen tail before mutating the live cache, so a corrupt
|
||||||
// append cannot leave a partially advanced state behind.
|
// append cannot leave a partially advanced state behind.
|
||||||
const events = session.events
|
const events = session.events
|
||||||
const pending: Array<{ seq: number; before: boolean }> = []
|
const pendingCuts: boolean[] = []
|
||||||
let depth = cache.depth
|
let inProgressToolCalls = cache.inProgressToolCalls
|
||||||
for (const node of tail) {
|
for (const node of tail) {
|
||||||
pending.push({ seq: node.seq, before: depth === 0 })
|
inProgressToolCalls += nodeDelta(eventForNode(events, node))
|
||||||
depth += nodeDelta(eventForNode(events, node))
|
if (inProgressToolCalls < 0) {
|
||||||
if (depth < 0) {
|
|
||||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
||||||
}
|
}
|
||||||
|
pendingCuts.push(inProgressToolCalls === 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
let previousSeq = nodes[cache.processedNodes - 1]?.seq
|
tail.forEach((node, offset) => cache.indexBySeq.set(node.seq, processed + offset))
|
||||||
for (const entry of pending) {
|
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
|
||||||
if (previousSeq !== undefined) cache.successorBySeq.set(previousSeq, entry.seq)
|
cache.inProgressToolCalls = inProgressToolCalls
|
||||||
cache.beforeSeq.set(entry.seq, entry.before)
|
|
||||||
cache.successorBySeq.set(entry.seq, null)
|
|
||||||
previousSeq = entry.seq
|
|
||||||
}
|
|
||||||
cache.processedNodes = nodes.length
|
|
||||||
cache.depth = depth
|
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,15 +81,32 @@ function balanceCache(session: Session): BalanceCache {
|
|||||||
const generation = surface.replaceGeneration
|
const generation = surface.replaceGeneration
|
||||||
const cached = balanceCacheBySession.get(session)
|
const cached = balanceCacheBySession.get(session)
|
||||||
|
|
||||||
if (cached === undefined || cached.generation !== generation || cached.processedNodes > nodes.length) {
|
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > nodes.length) {
|
||||||
const rebuilt = rebuildCache(session, nodes, generation)
|
// A rebuild is the same fold started from the empty-surface state, whose
|
||||||
|
// single leading cut is trivially balanced.
|
||||||
|
const rebuilt = extendCache(session, {
|
||||||
|
generation,
|
||||||
|
cutBalanced: [true],
|
||||||
|
indexBySeq: new Map(),
|
||||||
|
inProgressToolCalls: 0,
|
||||||
|
}, nodes)
|
||||||
balanceCacheBySession.set(session, rebuilt)
|
balanceCacheBySession.set(session, rebuilt)
|
||||||
return rebuilt
|
return rebuilt
|
||||||
}
|
}
|
||||||
if (cached.processedNodes < nodes.length) return extendCache(session, cached, nodes)
|
if (cached.cutBalanced.length - 1 < nodes.length) return extendCache(session, cached, nodes)
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Balance of the cut at a node's position plus offset, rejecting seqs outside current membership. */
|
||||||
|
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
|
||||||
|
const index = cache.indexBySeq.get(seq)
|
||||||
|
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
|
||||||
|
if (balanced === undefined) {
|
||||||
|
throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
|
||||||
|
}
|
||||||
|
return balanced
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the cut immediately before a current surface node is tool-pairing balanced.
|
* Whether the cut immediately before a current surface node is tool-pairing balanced.
|
||||||
* @param session - session whose surface is checked.
|
* @param session - session whose surface is checked.
|
||||||
@@ -128,12 +116,7 @@ function balanceCache(session: Session): BalanceCache {
|
|||||||
* matching log event, or a tool result has no preceding open call.
|
* matching log event, or a tool result has no preceding open call.
|
||||||
*/
|
*/
|
||||||
export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean {
|
export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean {
|
||||||
const cache = balanceCache(session)
|
return cutBalance(balanceCache(session), node.seq, 0)
|
||||||
const balanced = cache.beforeSeq.get(node.seq)
|
|
||||||
if (balanced === undefined) {
|
|
||||||
throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`)
|
|
||||||
}
|
|
||||||
return balanced
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,16 +128,5 @@ export function toolPairingBalancedBefore(session: Session, node: SurfaceNode):
|
|||||||
* matching log event, or a tool result has no preceding open call.
|
* matching log event, or a tool result has no preceding open call.
|
||||||
*/
|
*/
|
||||||
export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean {
|
export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean {
|
||||||
const cache = balanceCache(session)
|
return cutBalance(balanceCache(session), node.seq, 1)
|
||||||
const successor = cache.successorBySeq.get(node.seq)
|
|
||||||
if (successor === undefined) {
|
|
||||||
throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`)
|
|
||||||
}
|
|
||||||
if (successor === null) return cache.depth === 0
|
|
||||||
// Current membership and positional successors are cache-owned. A caller may
|
|
||||||
// retain a node across surface changes, so its mutable-looking `next` field is
|
|
||||||
// never authoritative for this query.
|
|
||||||
// The successor map and balance map are committed together.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
return cache.beforeSeq.get(successor)!
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ describe('tool-pairing surface identity', () => {
|
|||||||
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
|
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the cached positional successor instead of a caller node next field', () => {
|
it('ignores a caller-held node next field and answers from cached balances', () => {
|
||||||
const session = closedToolStep()
|
const session = closedToolStep()
|
||||||
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
|
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
|
||||||
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)
|
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)
|
||||||
|
|||||||
@@ -224,9 +224,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tokenMeter',
|
key: 'tokenMeter',
|
||||||
summary: 'Concrete registry and replay owner for all configured model meters.',
|
summary: 'Replay owner for one service-wide estimator and isolated per-session folds.',
|
||||||
methods: [
|
methods: [
|
||||||
'resolve(model: string): ModelTokenMeter',
|
'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement',
|
||||||
|
'estimateMessage(message: Message): number',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -776,10 +777,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
name: 'MessageSourceMap',
|
name: 'MessageSourceMap',
|
||||||
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
|
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'ModelTokenMeter',
|
|
||||||
declaration: 'export interface ModelTokenMeter {\n readonly model: string;\n readonly contextWindow: number;\n readonly charsPerToken: number;\n measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement;\n measureSurface(session: Session): TokenSurfaceMeasurement;\n estimateMessage(message: Message): number;\n}',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'PresetOption',
|
name: 'PresetOption',
|
||||||
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
|
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
|
||||||
@@ -1010,16 +1007,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'TokenMeasurement',
|
name: 'TokenMeasurement',
|
||||||
declaration: 'export interface TokenMeasurement {\n readonly model: string;\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n}',
|
declaration: 'export interface TokenMeasurement {\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n readonly surfaceTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'TokenMeasurementBaseline',
|
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<TokenUsage>;\n};',
|
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<TokenUsage>;\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',
|
name: 'TokenSurfaceNode',
|
||||||
declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n}',
|
declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n}',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a
|
|||||||
| Package | Role | ctx key |
|
| Package | Role | ctx key |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
|
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
|
||||||
| `token-meter/` | Replay-aware, per-model request and surface token measurement | `ctx.tokenMeter` |
|
| `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` |
|
||||||
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
|
| `llm-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`) |
|
| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
# @deepseek-ai/dsh-token-meter
|
# @deepseek-ai/dsh-token-meter
|
||||||
|
|
||||||
Replay-aware token measurement through `ctx.tokenMeter`. The service binds one stable meter to each configured model and advances isolated per-model/per-session folds from the durable session log. Compaction consumes it today; other pressure-sensitive plugins can reuse the same accounting without depending on `CompactService`.
|
Replay-aware token measurement through the singleton `ctx.tokenMeter` service. It advances one isolated fold per session from the durable log, so compaction and other pressure-sensitive plugins can share accounting without depending on `CompactService`.
|
||||||
|
|
||||||
## Profiles and configuration
|
## 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 |
|
| Key | Default | Contract |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| `models.<built-in>.contextWindow` | `128000` | Positive integer provider capacity. |
|
| `contextWindow` | `128000` | Positive integer service-wide context capacity. |
|
||||||
| `models.<model>.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.
|
The estimator intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. `contextWindow` is the only deployment setting. Direct construction validates it; Loader mounts first apply the package's Schemastery shape validation. Unrecognized top-level keys are rejected.
|
||||||
|
|
||||||
## Measurement contract
|
## Measurement contract
|
||||||
|
|
||||||
`ctx.tokenMeter.resolve(model)` returns a `ModelTokenMeter` with three operations:
|
`ctx.tokenMeter` directly exposes two operations:
|
||||||
|
|
||||||
- `measure(session, requestHeader?)` returns scalar request pressure at one consumed-log revision.
|
- `measure(session, requestHeader?)` returns request pressure and the current priced surface 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 message with the fixed heuristic.
|
||||||
- `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.
|
`measure()` synchronizes once and returns one detached, deeply immutable snapshot. `totalTokens` is request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override affects pressure fields only; the surface fields still describe the current session. Every call clones the positional nodes, so measurement is O(surface).
|
||||||
|
|
||||||
The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the handle's model and the canonical request envelope match the successful-call anchor. Otherwise the complete current envelope and surface are repriced under the requested model. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.
|
The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full heuristic anchor; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.
|
||||||
|
|
||||||
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output.
|
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output.
|
||||||
|
|
||||||
@@ -34,16 +30,12 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket
|
|||||||
- name: '@deepseek-ai/dsh-compact-basic'
|
- name: '@deepseek-ai/dsh-compact-basic'
|
||||||
```
|
```
|
||||||
|
|
||||||
Both plugins have usable defaults for the bundled DeepSeek profiles. Custom deployments can override only the fields that differ:
|
Both plugins have usable defaults. A deployment with a different capacity configures the meter once:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: '@deepseek-ai/dsh-token-meter'
|
- name: '@deepseek-ai/dsh-token-meter'
|
||||||
config:
|
config:
|
||||||
models:
|
contextWindow: 32768
|
||||||
deepseek-v4-flash:
|
|
||||||
charsPerToken: 2
|
|
||||||
local-model:
|
|
||||||
contextWindow: 32768
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
@@ -52,6 +44,7 @@ Indirectly, through consumers such as `dsh-compact-basic`; the service itself ad
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## 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.
|
- **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer.
|
||||||
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, or call-config changes deliberately fall back to full heuristic repricing.
|
- **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks.
|
||||||
|
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, model, or call-config changes deliberately fall back to full heuristic estimation.
|
||||||
- **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.
|
- **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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@deepseek-ai/dsh-token-meter",
|
"name": "@deepseek-ai/dsh-token-meter",
|
||||||
"description": "Replay-aware per-model token measurement service (ctx.tokenMeter) for the DeepSeek Harness",
|
"description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,192 +1,424 @@
|
|||||||
/**
|
/**
|
||||||
* Replay token-meter service with model-specific context capacity and pricing.
|
* Single replay-aware token-meter service for request and surface pressure.
|
||||||
*
|
*
|
||||||
* @module @deepseek-ai/dsh-token-meter
|
* @module @deepseek-ai/dsh-token-meter
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Session } from '@deepseek-ai/dsh-session'
|
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||||
import { ReplayModelTokenMeter } from './replay.ts'
|
import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||||
import type { ModelTokenProfile } from './replay.ts'
|
import { applyHeaderDelta, canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||||
import type {
|
import type {
|
||||||
ModelTokenMeter,
|
TokenMeasurement,
|
||||||
ModelTokenMeterConfig,
|
TokenMeasurementBaseline,
|
||||||
TokenMeterConfig,
|
TokenMeterConfig,
|
||||||
|
TokenSurfaceNode,
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
|
||||||
export type * from './types.ts'
|
export type * from './types.ts'
|
||||||
|
|
||||||
/** Exact error code for resolving a model without a configured profile. */
|
/** Default service-wide provider context capacity. */
|
||||||
export const TOKEN_METER_MODEL_UNCONFIGURED = 'TOKEN_METER_MODEL_UNCONFIGURED'
|
const DEFAULT_CONTEXT_WINDOW = 128_000
|
||||||
|
|
||||||
/** Exact error code for invalid token-meter configuration. */
|
/** Complete public configuration key set. */
|
||||||
export const TOKEN_METER_INVALID_CONFIG = 'TOKEN_METER_INVALID_CONFIG'
|
const TOKEN_METER_CONFIG_KEYS: ReadonlySet<string> = new Set(['contextWindow'])
|
||||||
|
|
||||||
/** Closed machine-routable token-meter failure taxonomy. */
|
/** Fixed text-density estimate used until exact tokenization is needed. */
|
||||||
export type TokenMeterErrorCode =
|
const CHARS_PER_TOKEN = 4
|
||||||
| typeof TOKEN_METER_MODEL_UNCONFIGURED
|
|
||||||
| typeof TOKEN_METER_INVALID_CONFIG
|
|
||||||
|
|
||||||
/** Built-in DeepSeek model profiles available with zero configuration. */
|
/** Per-block structural overhead for JSON framing and type tags. */
|
||||||
const BUILTIN_TOKEN_PROFILES: Readonly<Record<string, Readonly<ModelTokenProfile>>> = deepFreeze({
|
const BLOCK_OVERHEAD = 4
|
||||||
'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. */
|
/** Role-field framing overhead added to every priced message. */
|
||||||
export class TokenMeterError extends HarnessError {
|
const ROLE_OVERHEAD = 4
|
||||||
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) {
|
interface MeasurementAnchor {
|
||||||
super(message, code, options)
|
readonly header: EpochHeader | undefined
|
||||||
this.name = 'TokenMeterError'
|
readonly surfaceTokens: number
|
||||||
this.model = model
|
readonly baseline: Exclude<TokenMeasurementBaseline, { kind: 'none' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplayState {
|
||||||
|
consumedEvents: number
|
||||||
|
header: EpochHeader | undefined
|
||||||
|
surface: TokenSurfaceNode[]
|
||||||
|
surfaceTokens: number
|
||||||
|
stepStart: { turn: number; step: number; surfaceTokens: number } | undefined
|
||||||
|
anchor: MeasurementAnchor | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreparedSurfaceMutation {
|
||||||
|
readonly tokens: number
|
||||||
|
commit(state: ReplayState): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum disjoint provider usage buckets without double-counting reasoning output. */
|
||||||
|
function usageTokens(usage: TokenUsage): number {
|
||||||
|
return usage.inputTokens
|
||||||
|
+ (usage.cacheReadTokens ?? 0)
|
||||||
|
+ (usage.cacheWriteTokens ?? 0)
|
||||||
|
+ usage.outputTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare optional envelopes so a headerless estimate can track later surface deltas. */
|
||||||
|
function optionalHeaderEquals(
|
||||||
|
left: EpochHeader | undefined,
|
||||||
|
right: EpochHeader | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (left === undefined || right === undefined) return left === right
|
||||||
|
return headerEquals(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject stale or misspelled keys before defaults can hide them. */
|
||||||
|
function validateConfigKeys(config: TokenMeterConfig): void {
|
||||||
|
for (const key of Object.keys(config)) {
|
||||||
|
if (!TOKEN_METER_CONFIG_KEYS.has(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`TokenMeterConfig: unknown key "${key}" (allowed: contextWindow)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve and validate the one service-wide context capacity. */
|
||||||
|
function resolveContextWindow(config: TokenMeterConfig): number {
|
||||||
|
validateConfigKeys(config)
|
||||||
|
const contextWindow = config.contextWindow === undefined
|
||||||
|
? DEFAULT_CONTEXT_WINDOW
|
||||||
|
: config.contextWindow
|
||||||
|
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
`TokenMeterConfig: contextWindow (${contextWindow}) must be a positive integer`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return contextWindow
|
||||||
|
}
|
||||||
|
|
||||||
declare module 'cordis' {
|
declare module 'cordis' {
|
||||||
interface Context {
|
interface Context {
|
||||||
tokenMeter: TokenMeterService
|
tokenMeter: TokenMeterService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate and detach all configured model profiles. */
|
/** Replay owner for one service-wide estimator and isolated per-session folds. */
|
||||||
function resolveProfiles(config: TokenMeterConfig): readonly ModelTokenProfile[] {
|
|
||||||
const profiles = new Map<string, ModelTokenProfile>()
|
|
||||||
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<string, unknown>)) {
|
|
||||||
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 {
|
export class TokenMeterService extends Service {
|
||||||
static Config: z<TokenMeterConfig> = z.object({
|
static Config: z<TokenMeterConfig> = z.object({
|
||||||
models: z.dict(z.object({
|
contextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
|
||||||
contextWindow: z.number(),
|
|
||||||
charsPerToken: z.number(),
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
private readonly meters = new Map<string, ReplayModelTokenMeter>()
|
/** Provider context-window capacity used by pressure consumers. */
|
||||||
|
readonly contextWindow: number
|
||||||
|
|
||||||
|
private readonly states = new WeakMap<Session, ReplayState>()
|
||||||
|
|
||||||
constructor(ctx: Context, config: TokenMeterConfig = {}) {
|
constructor(ctx: Context, config: TokenMeterConfig = {}) {
|
||||||
super(ctx, 'tokenMeter')
|
super(ctx, 'tokenMeter')
|
||||||
for (const profile of resolveProfiles(config)) {
|
this.contextWindow = resolveContextWindow(config)
|
||||||
this.meters.set(profile.model, new ReplayModelTokenMeter(profile))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Readers catch up independently, while eager observation bounds ordinary
|
// Readers catch up independently, while eager observation bounds ordinary
|
||||||
// read latency. A reader in an earlier listener consumes the new event;
|
// read latency without creating state for sessions no consumer has read.
|
||||||
// this listener then sees the same revision and performs no duplicate fold.
|
|
||||||
ctx.on('session/event', (session) => {
|
ctx.on('session/event', (session) => {
|
||||||
this._observe(session)
|
if (this.states.has(session)) this._sync(session)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve one stable model-bound replay handle.
|
* Measure current request pressure and surface through the durable tail.
|
||||||
* @param model - exact routed model name.
|
*
|
||||||
* @throws {@link TokenMeterError} with `TOKEN_METER_MODEL_UNCONFIGURED` when no profile exists.
|
* Provider usage is reused only when the latest successful call's canonical
|
||||||
* @returns the configured handle for this model.
|
* request envelope matches `requestHeader` and its total is no lower than
|
||||||
|
* that call's full heuristic anchor; otherwise the complete envelope and
|
||||||
|
* surface are heuristically repriced.
|
||||||
|
*
|
||||||
|
* `requestHeader` affects request pressure only; surface fields always
|
||||||
|
* describe the current session surface. Every call clones those positional
|
||||||
|
* nodes, so measurement is O(surface).
|
||||||
|
*
|
||||||
|
* @param session - session to replay through its current durable tail.
|
||||||
|
* @param requestHeader - optional effective request envelope replacing the latest logged header.
|
||||||
|
* @returns a detached deeply immutable pressure and surface measurement.
|
||||||
*/
|
*/
|
||||||
resolve(model: string): ModelTokenMeter {
|
measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement {
|
||||||
const meter = this.meters.get(model)
|
const state = this._sync(session)
|
||||||
if (meter === undefined) {
|
const header = requestHeader === undefined
|
||||||
throw new TokenMeterError(
|
? state.header
|
||||||
`token meter has no profile for model "${model}"`,
|
: canonicalHeader(requestHeader)
|
||||||
TOKEN_METER_MODEL_UNCONFIGURED,
|
const anchor = state.anchor
|
||||||
model,
|
|
||||||
)
|
let baseline: TokenMeasurementBaseline
|
||||||
|
let surfaceDeltaTokens: number
|
||||||
|
if (anchor !== undefined && optionalHeaderEquals(anchor.header, header)) {
|
||||||
|
baseline = anchor.baseline
|
||||||
|
surfaceDeltaTokens = state.surfaceTokens - anchor.surfaceTokens
|
||||||
|
} else if (header === undefined && state.surfaceTokens === 0) {
|
||||||
|
baseline = { kind: 'none', tokens: 0 }
|
||||||
|
surfaceDeltaTokens = 0
|
||||||
|
} else {
|
||||||
|
baseline = {
|
||||||
|
kind: 'estimated',
|
||||||
|
tokens: this._estimateHeader(header) + state.surfaceTokens,
|
||||||
|
}
|
||||||
|
surfaceDeltaTokens = 0
|
||||||
}
|
}
|
||||||
return meter
|
|
||||||
|
return deepFreeze(structuredClone({
|
||||||
|
logRevision: state.consumedEvents,
|
||||||
|
baseline,
|
||||||
|
surfaceDeltaTokens,
|
||||||
|
totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens),
|
||||||
|
surfaceTokens: state.surfaceTokens,
|
||||||
|
nodes: state.surface,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Advance every configured model's isolated replay fold. */
|
/**
|
||||||
private _observe(session: Session): void {
|
* Heuristically price one model-visible message.
|
||||||
for (const meter of this.meters.values()) meter.observeIfActive(session)
|
* @param message - message to price without mutation.
|
||||||
|
* @returns content and role-framing tokens under the fixed service heuristic.
|
||||||
|
*/
|
||||||
|
estimateMessage(message: Message): number {
|
||||||
|
return this._estimateContent(message.content) + ROLE_OVERHEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catch one session's fold up to the current durable tail. */
|
||||||
|
private _sync(session: Session): ReplayState {
|
||||||
|
let state = this.states.get(session)
|
||||||
|
if (state === undefined) {
|
||||||
|
state = {
|
||||||
|
consumedEvents: 0,
|
||||||
|
header: undefined,
|
||||||
|
surface: [],
|
||||||
|
surfaceTokens: 0,
|
||||||
|
stepStart: undefined,
|
||||||
|
anchor: undefined,
|
||||||
|
}
|
||||||
|
this.states.set(session, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (state.consumedEvents < session.events.length) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- contiguous session seqs index the durable log
|
||||||
|
const event = session.events[state.consumedEvents]!
|
||||||
|
this._foldEvent(session, state, event)
|
||||||
|
state.consumedEvents += 1
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and prepare every fallible part before mutating replay state.
|
||||||
|
* A malformed event remains unread on every retry instead of partially
|
||||||
|
* applying the same mutation more than once.
|
||||||
|
*/
|
||||||
|
private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void {
|
||||||
|
let nextHeader = state.header
|
||||||
|
let nextStepStart = state.stepStart
|
||||||
|
let nextAnchor = state.anchor
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'request/header':
|
||||||
|
nextHeader = canonicalHeader(event.data.header)
|
||||||
|
break
|
||||||
|
case 'request/header-delta':
|
||||||
|
if (state.header === undefined) {
|
||||||
|
throw new Error(`token meter: request/header-delta at seq ${event.seq} has no preceding header`)
|
||||||
|
}
|
||||||
|
nextHeader = applyHeaderDelta(state.header, event.data)
|
||||||
|
break
|
||||||
|
case 'step/start':
|
||||||
|
if (state.stepStart !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nextStepStart = { ...event.data, surfaceTokens: state.surfaceTokens }
|
||||||
|
break
|
||||||
|
case 'step/end':
|
||||||
|
if (state.stepStart === undefined
|
||||||
|
|| state.stepStart.turn !== event.data.turn
|
||||||
|
|| state.stepStart.step !== event.data.step) {
|
||||||
|
throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`)
|
||||||
|
}
|
||||||
|
nextStepStart = undefined
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const surface = isSurfaceEvent(event)
|
||||||
|
? this._prepareSurfaceMutation(session, state, event)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (event.type === 'assistant/message') {
|
||||||
|
const stepStart = state.stepStart
|
||||||
|
if (stepStart === undefined
|
||||||
|
|| stepStart.turn !== event.data.turn
|
||||||
|
|| stepStart.step !== event.data.step) {
|
||||||
|
throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assistant/message is surface-mandatory at every append/seed boundary.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const eventTokens = surface!.tokens
|
||||||
|
if (event.data.usage !== undefined && nextHeader !== undefined) {
|
||||||
|
const providerAssistantTokens = this._estimateProviderAssistant(
|
||||||
|
session,
|
||||||
|
event,
|
||||||
|
eventTokens,
|
||||||
|
)
|
||||||
|
const anchorSurfaceTokens = stepStart.surfaceTokens + providerAssistantTokens
|
||||||
|
const providerTokens = usageTokens(event.data.usage)
|
||||||
|
const estimatedAnchorTokens = this._estimateHeader(nextHeader) + anchorSurfaceTokens
|
||||||
|
nextAnchor = {
|
||||||
|
header: nextHeader,
|
||||||
|
surfaceTokens: anchorSurfaceTokens,
|
||||||
|
// Signed heuristic deltas remain conservative only from an anchor
|
||||||
|
// that is at least as large as the matching full heuristic price.
|
||||||
|
baseline: providerTokens >= estimatedAnchorTokens
|
||||||
|
? { kind: 'usage', tokens: providerTokens, usage: event.data.usage }
|
||||||
|
: { kind: 'estimated', tokens: estimatedAnchorTokens },
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const anchorSurfaceTokens = stepStart.surfaceTokens + eventTokens
|
||||||
|
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<number>()
|
||||||
|
for (const seq of sourceSeqs) {
|
||||||
|
if (seq >= event.seq) {
|
||||||
|
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not earlier`)
|
||||||
|
}
|
||||||
|
if (seen.has(seq)) {
|
||||||
|
throw new Error(`token meter: assistant/message at seq ${event.seq} repeats source seq ${seq}`)
|
||||||
|
}
|
||||||
|
seen.add(seq)
|
||||||
|
// Session construction validates contiguous seqs, and the explicit
|
||||||
|
// earlier-than-assistant check above therefore guarantees existence.
|
||||||
|
const source = session.events[seq]
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const sourceEvent = source!
|
||||||
|
if (sourceEvent.type !== 'assistant/chunk') {
|
||||||
|
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`)
|
||||||
|
}
|
||||||
|
if (sourceEvent.data.turn !== event.data.turn || sourceEvent.data.step !== event.data.step) {
|
||||||
|
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} belongs to another step`)
|
||||||
|
}
|
||||||
|
assembler.push(sourceEvent.data.chunk)
|
||||||
|
}
|
||||||
|
const providerMessage = assembler.message()
|
||||||
|
return providerMessage.content.length === 0 ? 0 : this.estimateMessage(providerMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price content blocks recursively under the fixed density heuristic. */
|
||||||
|
private _estimateContent(blocks: readonly ContentBlock[]): number {
|
||||||
|
let tokens = 0
|
||||||
|
for (const block of blocks) {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'text':
|
||||||
|
case 'reasoning':
|
||||||
|
tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
|
||||||
|
break
|
||||||
|
case 'tool-call':
|
||||||
|
tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN)
|
||||||
|
+ Math.ceil(block.arguments.length / CHARS_PER_TOKEN)
|
||||||
|
+ BLOCK_OVERHEAD
|
||||||
|
break
|
||||||
|
case 'tool-result':
|
||||||
|
tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// ContentBlockMap is merge-extensible; unknown blocks retain a
|
||||||
|
// conservative structural JSON price under the fixed heuristic.
|
||||||
|
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price the canonical non-surface request envelope. */
|
||||||
|
private _estimateHeader(header: EpochHeader | undefined): number {
|
||||||
|
if (header === undefined) return 0
|
||||||
|
let tokens = 0
|
||||||
|
for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message)
|
||||||
|
if (header.system !== undefined) {
|
||||||
|
tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
|
||||||
|
}
|
||||||
|
if (header.tools !== undefined && header.tools.length > 0) {
|
||||||
|
tokens += Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,367 +0,0 @@
|
|||||||
/**
|
|
||||||
* Model-bound transactional replay of request headers, surface mutations, and
|
|
||||||
* successful-call token anchors.
|
|
||||||
*
|
|
||||||
* @module @deepseek-ai/dsh-token-meter/replay
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
|
|
||||||
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
||||||
import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session'
|
|
||||||
import { applyHeaderDelta, canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
|
||||||
import type {
|
|
||||||
ModelTokenMeter,
|
|
||||||
TokenMeasurement,
|
|
||||||
TokenMeasurementBaseline,
|
|
||||||
TokenSurfaceMeasurement,
|
|
||||||
TokenSurfaceNode,
|
|
||||||
} from './types.ts'
|
|
||||||
|
|
||||||
/** Internal validated pricing profile. */
|
|
||||||
export interface ModelTokenProfile {
|
|
||||||
readonly model: string
|
|
||||||
readonly contextWindow: number
|
|
||||||
readonly charsPerToken: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-block structural overhead for JSON framing and type tags. */
|
|
||||||
const BLOCK_OVERHEAD = 4
|
|
||||||
|
|
||||||
/** Role-field framing overhead added to every priced message. */
|
|
||||||
const ROLE_OVERHEAD = 4
|
|
||||||
|
|
||||||
interface UsageAnchor {
|
|
||||||
readonly header: EpochHeader
|
|
||||||
readonly surfaceTokens: number
|
|
||||||
readonly baseline: Exclude<TokenMeasurementBaseline, { kind: 'none' }>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Session, ReplayState>()
|
|
||||||
|
|
||||||
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<number>()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,21 +4,12 @@
|
|||||||
* @module @deepseek-ai/dsh-token-meter/types
|
* @module @deepseek-ai/dsh-token-meter/types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
import type { 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. */
|
/** Token-meter plugin configuration. */
|
||||||
export interface TokenMeterConfig {
|
export interface TokenMeterConfig {
|
||||||
/** Built-in field overrides and custom model profiles, keyed by routed model name. */
|
/** Service-wide context-window capacity in tokens. Defaults to `128000`. */
|
||||||
models?: Record<string, ModelTokenMeterConfig>
|
contextWindow?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The baseline from which a signed surface delta produces current pressure. */
|
/** The baseline from which a signed surface delta produces current pressure. */
|
||||||
@@ -27,10 +18,8 @@ export type TokenMeasurementBaseline =
|
|||||||
| { readonly kind: 'estimated'; readonly tokens: number }
|
| { readonly kind: 'estimated'; readonly tokens: number }
|
||||||
| { readonly kind: 'usage'; readonly tokens: number; readonly usage: Readonly<TokenUsage> }
|
| { readonly kind: 'usage'; readonly tokens: number; readonly usage: Readonly<TokenUsage> }
|
||||||
|
|
||||||
/** Detached immutable scalar pressure at one consumed session-log revision. */
|
/** Detached immutable request-pressure and surface snapshot at one consumed log revision. */
|
||||||
export interface TokenMeasurement {
|
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. */
|
/** Number of durable events consumed; equal to the next unread event seq. */
|
||||||
readonly logRevision: number
|
readonly logRevision: number
|
||||||
/** Provider or heuristic anchor used for this measurement. */
|
/** Provider or heuristic anchor used for this measurement. */
|
||||||
@@ -39,6 +28,10 @@ export interface TokenMeasurement {
|
|||||||
readonly surfaceDeltaTokens: number
|
readonly surfaceDeltaTokens: number
|
||||||
/** Non-negative current request-and-response pressure. */
|
/** Non-negative current request-and-response pressure. */
|
||||||
readonly totalTokens: number
|
readonly totalTokens: number
|
||||||
|
/** Total heuristic tokens across the current surface. */
|
||||||
|
readonly surfaceTokens: number
|
||||||
|
/** Current surface nodes in positional head-to-tail order. */
|
||||||
|
readonly nodes: readonly TokenSurfaceNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One token-priced node in the current ordered session surface. */
|
/** One token-priced node in the current ordered session surface. */
|
||||||
@@ -48,54 +41,3 @@ export interface TokenSurfaceNode {
|
|||||||
/** Heuristic tokens for the exact message projected by this node. */
|
/** Heuristic tokens for the exact message projected by this node. */
|
||||||
readonly tokens: number
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
|||||||
import type { ContentBlock, Message, TokenUsage } 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 SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||||
import type { EpochHeader } from '@deepseek-ai/dsh-session'
|
import type { EpochHeader } from '@deepseek-ai/dsh-session'
|
||||||
import TokenMeterService, {
|
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||||
TOKEN_METER_INVALID_CONFIG,
|
import type { TokenMeasurement, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
|
||||||
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, 'config'> = {}): EpochHeader {
|
function header(model: string, extras: Omit<EpochHeader, 'config'> = {}): EpochHeader {
|
||||||
return canonicalHeader({ config: { model }, ...extras })
|
return canonicalHeader({ config: { model }, ...extras })
|
||||||
@@ -75,70 +71,34 @@ function meter(config: TokenMeterConfig = {}): TokenMeterService {
|
|||||||
return new TokenMeterService(new Context(), config)
|
return new TokenMeterService(new Context(), config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectSurfaceTotal(measurement: TokenMeasurement): void {
|
||||||
|
expect(measurement.nodes.reduce((total, node) => total + node.tokens, 0))
|
||||||
|
.toBe(measurement.surfaceTokens)
|
||||||
|
}
|
||||||
|
|
||||||
describe('TokenMeterService configuration and registration', () => {
|
describe('TokenMeterService configuration and registration', () => {
|
||||||
it('provides immutable zero-config DeepSeek profiles', () => {
|
it('provides one zero-config context window', () => {
|
||||||
const service = meter()
|
const service = meter()
|
||||||
expect(service.resolve('deepseek-v4-flash')).toMatchObject({
|
expect(service.contextWindow).toBe(128_000)
|
||||||
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', () => {
|
it('accepts one service-wide context-window override', () => {
|
||||||
const service = meter({
|
expect(meter({ contextWindow: 32_000 }).contextWindow).toBe(32_000)
|
||||||
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', () => {
|
it.each(['models', 'contextWidow'])('rejects unknown top-level config key %s', (key) => {
|
||||||
const service = meter()
|
expect(() => meter({ [key]: {} }))
|
||||||
let thrown: unknown
|
.toThrow(`TokenMeterConfig: unknown key "${key}"`)
|
||||||
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([
|
it.each([
|
||||||
[{ models: null }, /models must be an object/],
|
{ contextWindow: 0 },
|
||||||
[{ models: [] }, /models must be an object/],
|
{ contextWindow: -1 },
|
||||||
[{ models: { custom: {} } }, /requires contextWindow/],
|
{ contextWindow: 1.5 },
|
||||||
[{ models: { '': { contextWindow: 1 } } }, /must not be empty/],
|
{ contextWindow: Number.NaN },
|
||||||
[{ models: { custom: { contextWindow: 0 } } }, /positive integer/],
|
{ contextWindow: null },
|
||||||
[{ models: { custom: { contextWindow: 1.5 } } }, /positive integer/],
|
] as unknown as TokenMeterConfig[])('rejects invalid context capacity %#', (config) => {
|
||||||
[{ models: { custom: { contextWindow: 1, charsPerToken: 0 } } }, /positive finite/],
|
expect(() => meter(config)).toThrow(/contextWindow .* positive integer/)
|
||||||
[{ 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 () => {
|
it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => {
|
||||||
@@ -151,9 +111,9 @@ describe('TokenMeterService configuration and registration', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ModelTokenMeter pricing', () => {
|
describe('TokenMeterService pricing', () => {
|
||||||
it('prices every built-in content shape and merge-extended blocks', () => {
|
it('prices every built-in content shape and merge-extended blocks with one fixed heuristic', () => {
|
||||||
const handle = meter({ models: { custom: { contextWindow: 100, charsPerToken: 2 } } }).resolve('custom')
|
const service = meter({ contextWindow: 100 })
|
||||||
const blocks: ContentBlock[] = [
|
const blocks: ContentBlock[] = [
|
||||||
{ type: 'text', text: 'abcd' },
|
{ type: 'text', text: 'abcd' },
|
||||||
{ type: 'reasoning', text: 'ab' },
|
{ type: 'reasoning', text: 'ab' },
|
||||||
@@ -166,55 +126,66 @@ describe('ModelTokenMeter pricing', () => {
|
|||||||
},
|
},
|
||||||
{ type: 'future-block', payload: 'abcd' } as unknown as ContentBlock,
|
{ type: 'future-block', payload: 'abcd' } as unknown as ContentBlock,
|
||||||
]
|
]
|
||||||
const estimated = handle.estimateMessage({ role: 'assistant', content: blocks })
|
const estimated = service.estimateMessage({ role: 'assistant', content: blocks })
|
||||||
expect(estimated).toBeGreaterThan(30)
|
expect(estimated).toBeGreaterThan(30)
|
||||||
expect(handle.estimateMessage(textMessage('abcd'))).toBe(10)
|
expect(service.estimateMessage(textMessage('abcd'))).toBe(9)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns a detached deeply immutable empty measurement', () => {
|
it('returns a detached deeply immutable empty measurement', () => {
|
||||||
const handle = meter().resolve('deepseek-v4-flash')
|
const service = meter()
|
||||||
const session = new Session(SessionId('empty'))
|
const session = new Session(SessionId('empty'))
|
||||||
const result = handle.measure(session)
|
const result = service.measure(session)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
model: 'deepseek-v4-flash',
|
|
||||||
logRevision: 0,
|
logRevision: 0,
|
||||||
baseline: { kind: 'none', tokens: 0 },
|
baseline: { kind: 'none', tokens: 0 },
|
||||||
surfaceDeltaTokens: 0,
|
surfaceDeltaTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
|
surfaceTokens: 0,
|
||||||
|
nodes: [],
|
||||||
})
|
})
|
||||||
expect(Object.isFrozen(result)).toBe(true)
|
expect(Object.isFrozen(result)).toBe(true)
|
||||||
expect(Object.isFrozen(result.baseline)).toBe(true)
|
expect(Object.isFrozen(result.baseline)).toBe(true)
|
||||||
|
expect(Object.isFrozen(result.nodes)).toBe(true)
|
||||||
|
expectSurfaceTotal(result)
|
||||||
expect(() => {
|
expect(() => {
|
||||||
;(result as { totalTokens: number }).totalTokens = 1
|
;(result as { totalTokens: number }).totalTokens = 1
|
||||||
}).toThrow(TypeError)
|
}).toThrow(TypeError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps earlier scalar and surface snapshots detached from later replay', () => {
|
it('keeps an earlier unified snapshot detached from later replay', () => {
|
||||||
const handle = meter().resolve('deepseek-v4-flash')
|
const service = meter()
|
||||||
const session = new Session(SessionId('detached'))
|
const session = new Session(SessionId('detached'))
|
||||||
session.append('user/message', {
|
session.append('user/message', {
|
||||||
content: [{ type: 'text', text: 'first' }],
|
content: [{ type: 'text', text: 'first' }],
|
||||||
source: { kind: 'user' },
|
source: { kind: 'user' },
|
||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
const scalar = handle.measure(session)
|
const snapshot = service.measure(session)
|
||||||
const surface = handle.measureSurface(session)
|
const snapshotCopy = structuredClone(snapshot)
|
||||||
const scalarCopy = structuredClone(scalar)
|
expect(Object.isFrozen(snapshot.nodes)).toBe(true)
|
||||||
const surfaceCopy = structuredClone(surface)
|
expect(Object.isFrozen(snapshot.nodes[0])).toBe(true)
|
||||||
|
expectSurfaceTotal(snapshot)
|
||||||
|
expect(() => {
|
||||||
|
;(snapshot.nodes as Array<{ seq: number; tokens: number }>).push({ seq: 99, tokens: 1 })
|
||||||
|
}).toThrow(TypeError)
|
||||||
|
expect(() => {
|
||||||
|
;(snapshot.nodes[0] as { seq: number; tokens: number }).tokens = 1
|
||||||
|
}).toThrow(TypeError)
|
||||||
|
|
||||||
session.append('user/message', {
|
session.append('user/message', {
|
||||||
content: [{ type: 'text', text: 'second' }],
|
content: [{ type: 'text', text: 'second' }],
|
||||||
source: { kind: 'user' },
|
source: { kind: 'user' },
|
||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
expect(handle.measure(session).logRevision).toBe(2)
|
const advanced = service.measure(session)
|
||||||
expect(handle.measureSurface(session).nodes).toHaveLength(2)
|
expect(advanced.logRevision).toBe(2)
|
||||||
expect(scalar).toEqual(scalarCopy)
|
expect(advanced.nodes).toHaveLength(2)
|
||||||
expect(surface).toEqual(surfaceCopy)
|
expectSurfaceTotal(advanced)
|
||||||
expect(scalar.logRevision).toBe(1)
|
expect(snapshot).toEqual(snapshotCopy)
|
||||||
expect(surface.nodes).toHaveLength(1)
|
expect(snapshot.logRevision).toBe(1)
|
||||||
|
expect(snapshot.nodes).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prices header, prefix, tools, and surface when no reusable usage exists', () => {
|
it('prices header, prefix, tools, and surface when no reusable usage exists', () => {
|
||||||
const handle = meter().resolve('deepseek-v4-flash')
|
const service = meter()
|
||||||
const session = new Session(SessionId('heuristic'))
|
const session = new Session(SessionId('heuristic'))
|
||||||
session.append('user/message', {
|
session.append('user/message', {
|
||||||
content: [{ type: 'text', text: 'question' }],
|
content: [{ type: 'text', text: 'question' }],
|
||||||
@@ -225,10 +196,29 @@ describe('ModelTokenMeter pricing', () => {
|
|||||||
messagePrefix: [textMessage('prefix')],
|
messagePrefix: [textMessage('prefix')],
|
||||||
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
|
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
|
||||||
}))
|
}))
|
||||||
const result = handle.measure(session)
|
const result = service.measure(session)
|
||||||
expect(result.baseline.kind).toBe('estimated')
|
expect(result.baseline.kind).toBe('estimated')
|
||||||
expect(result.totalTokens).toBeGreaterThan(handle.measureSurface(session).totalTokens)
|
expect(result.totalTokens).toBeGreaterThan(result.surfaceTokens)
|
||||||
expect(result.logRevision).toBe(session.events.length)
|
expect(result.logRevision).toBe(session.events.length)
|
||||||
|
expectSurfaceTotal(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps request-header overrides out of the returned surface', () => {
|
||||||
|
const service = meter()
|
||||||
|
const session = new Session(SessionId('override-surface'))
|
||||||
|
session.append('user/message', {
|
||||||
|
content: [{ type: 'text', text: 'question' }],
|
||||||
|
source: { kind: 'user' },
|
||||||
|
}, { surfaceOp: 'append' })
|
||||||
|
|
||||||
|
const logged = service.measure(session)
|
||||||
|
const overridden = service.measure(session, header('another-model', {
|
||||||
|
system: 'large override '.repeat(100),
|
||||||
|
}))
|
||||||
|
expect(overridden.totalTokens).toBeGreaterThan(logged.totalTokens)
|
||||||
|
expect(overridden.surfaceTokens).toBe(logged.surfaceTokens)
|
||||||
|
expect(overridden.nodes).toEqual(logged.nodes)
|
||||||
|
expectSurfaceTotal(overridden)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -242,7 +232,7 @@ describe('replay anchors and surface folds', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('uses disjoint provider usage and signed durable-output rewrites', () => {
|
it('uses disjoint provider usage and signed durable-output rewrites', () => {
|
||||||
const handle = meter().resolve('deepseek-v4-flash')
|
const service = meter()
|
||||||
const session = new Session(SessionId('usage'))
|
const session = new Session(SessionId('usage'))
|
||||||
session.append('user/message', {
|
session.append('user/message', {
|
||||||
content: [{ type: 'text', text: 'before' }],
|
content: [{ type: 'text', text: 'before' }],
|
||||||
@@ -253,7 +243,7 @@ describe('replay anchors and surface folds', () => {
|
|||||||
durableText: 'a much longer rewritten durable assistant answer',
|
durableText: 'a much longer rewritten durable assistant answer',
|
||||||
usage: USAGE,
|
usage: USAGE,
|
||||||
})
|
})
|
||||||
const result = handle.measure(session)
|
const result = service.measure(session)
|
||||||
expect(result.baseline).toMatchObject({ kind: 'usage', tokens: 34, usage: USAGE })
|
expect(result.baseline).toMatchObject({ kind: 'usage', tokens: 34, usage: USAGE })
|
||||||
expect(result.surfaceDeltaTokens).toBeGreaterThan(0)
|
expect(result.surfaceDeltaTokens).toBeGreaterThan(0)
|
||||||
expect(result.totalTokens).toBe(34 + result.surfaceDeltaTokens)
|
expect(result.totalTokens).toBe(34 + result.surfaceDeltaTokens)
|
||||||
@@ -262,21 +252,51 @@ describe('replay anchors and surface folds', () => {
|
|||||||
}).toThrow(TypeError)
|
}).toThrow(TypeError)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('selects a heuristic anchor when provider usage would undercut its scale', () => {
|
||||||
|
const service = meter()
|
||||||
|
const session = new Session(SessionId('low-usage-anchor'))
|
||||||
|
const system = 'system context'
|
||||||
|
const requestHeader = header('deepseek-v4-flash', { system })
|
||||||
|
appendSuccessfulCall(session, requestHeader, {
|
||||||
|
providerText: 'abcd'.repeat(512),
|
||||||
|
usage: { inputTokens: 20, outputTokens: 7 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const anchored = service.measure(session)
|
||||||
|
expect(anchored.baseline.kind).toBe('estimated')
|
||||||
|
const assistant = anchored.nodes[0]!.seq
|
||||||
|
session.append('user/message', {
|
||||||
|
content: [{ type: 'text', text: 'short' }],
|
||||||
|
source: { kind: 'plugin', plugin: 'test' },
|
||||||
|
}, {
|
||||||
|
surfaceOp: { op: 'replace', start: assistant, end: assistant },
|
||||||
|
sourceEventSeqs: [assistant],
|
||||||
|
})
|
||||||
|
|
||||||
|
const shrunken = service.measure(session)
|
||||||
|
expect(27 + shrunken.surfaceDeltaTokens).toBeLessThan(0)
|
||||||
|
expect(shrunken.totalTokens).toBeGreaterThan(0)
|
||||||
|
expect(shrunken.totalTokens).toBe(service.measure(
|
||||||
|
session,
|
||||||
|
header('different-model', { system }),
|
||||||
|
).totalTokens)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses an estimated anchor when provider usage is absent', () => {
|
it('uses an estimated anchor when provider usage is absent', () => {
|
||||||
const handle = meter().resolve('deepseek-v4-flash')
|
const service = meter()
|
||||||
const session = new Session(SessionId('missing-usage'))
|
const session = new Session(SessionId('missing-usage'))
|
||||||
appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), {
|
appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), {
|
||||||
providerText: 'provider',
|
providerText: 'provider',
|
||||||
durableText: 'rewritten',
|
durableText: 'rewritten',
|
||||||
})
|
})
|
||||||
const anchored = handle.measure(session)
|
const anchored = service.measure(session)
|
||||||
expect(anchored.baseline.kind).toBe('estimated')
|
expect(anchored.baseline.kind).toBe('estimated')
|
||||||
expect(anchored.surfaceDeltaTokens).toBe(0)
|
expect(anchored.surfaceDeltaTokens).toBe(0)
|
||||||
session.append('user/message', {
|
session.append('user/message', {
|
||||||
content: [{ type: 'text', text: 'later' }],
|
content: [{ type: 'text', text: 'later' }],
|
||||||
source: { kind: 'user' },
|
source: { kind: 'user' },
|
||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
const advanced = handle.measure(session)
|
const advanced = service.measure(session)
|
||||||
expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0)
|
expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -295,24 +315,17 @@ describe('replay anchors and surface folds', () => {
|
|||||||
usage: USAGE,
|
usage: USAGE,
|
||||||
provenance: 'absent',
|
provenance: 'absent',
|
||||||
})
|
})
|
||||||
const handle = meter().resolve('deepseek-v4-flash')
|
const service = meter()
|
||||||
expect(handle.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0)
|
expect(service.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0)
|
||||||
expect(handle.measure(legacy).surfaceDeltaTokens).toBe(0)
|
expect(service.measure(legacy).surfaceDeltaTokens).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves one model anchor across another model success and reuses it after switching back', () => {
|
it('keeps only the latest successful request anchor across model switches', () => {
|
||||||
const service = meter({
|
const service = meter({ contextWindow: 1_000 })
|
||||||
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 session = new Session(SessionId('switch'))
|
||||||
const alphaHeader = header('alpha', { system: 'same envelope' })
|
const alphaHeader = header('alpha', { system: 'same envelope' })
|
||||||
appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' })
|
appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' })
|
||||||
expect(alpha.measure(session).baseline.kind).toBe('usage')
|
expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 34 })
|
||||||
|
|
||||||
appendSuccessfulCall(session, header('beta'), {
|
appendSuccessfulCall(session, header('beta'), {
|
||||||
turn: 1,
|
turn: 1,
|
||||||
@@ -320,34 +333,33 @@ describe('replay anchors and surface folds', () => {
|
|||||||
usage: { inputTokens: 100, outputTokens: 50 },
|
usage: { inputTokens: 100, outputTokens: 50 },
|
||||||
providerText: 'beta response',
|
providerText: 'beta response',
|
||||||
})
|
})
|
||||||
expect(alpha.measure(session).baseline.kind).toBe('estimated')
|
expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 150 })
|
||||||
expect(beta.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 150 })
|
|
||||||
|
|
||||||
appendHeader(session, alphaHeader)
|
appendHeader(session, alphaHeader)
|
||||||
const switchedBack = alpha.measure(session)
|
const switchedBack = service.measure(session)
|
||||||
expect(switchedBack.baseline).toMatchObject({ kind: 'usage', tokens: 34 })
|
expect(switchedBack.baseline.kind).toBe('estimated')
|
||||||
expect(switchedBack.surfaceDeltaTokens).toBeGreaterThan(0)
|
expect(switchedBack.surfaceDeltaTokens).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('invalidates usage for any canonical envelope change or explicit override', () => {
|
it('invalidates usage for any canonical envelope change or explicit override', () => {
|
||||||
const handle = meter().resolve('deepseek-v4-flash')
|
const service = meter()
|
||||||
const session = new Session(SessionId('envelope'))
|
const session = new Session(SessionId('envelope'))
|
||||||
const anchoredHeader = header('deepseek-v4-flash', { system: 'one' })
|
const anchoredHeader = header('deepseek-v4-flash', { system: 'one' })
|
||||||
appendSuccessfulCall(session, anchoredHeader, { usage: USAGE })
|
appendSuccessfulCall(session, anchoredHeader, { usage: USAGE })
|
||||||
expect(handle.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage')
|
expect(service.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage')
|
||||||
expect(handle.measure(session, header('deepseek-v4-flash', { system: 'two' })).baseline.kind)
|
expect(service.measure(session, header('deepseek-v4-flash', { system: 'two' })).baseline.kind)
|
||||||
.toBe('estimated')
|
.toBe('estimated')
|
||||||
expect(handle.measure(session, header('deepseek-v4-pro', { system: 'one' })).baseline.kind)
|
expect(service.measure(session, header('deepseek-v4-pro', { system: 'one' })).baseline.kind)
|
||||||
.toBe('estimated')
|
.toBe('estimated')
|
||||||
expect(handle.measure(session, {
|
expect(service.measure(session, {
|
||||||
...anchoredHeader,
|
...anchoredHeader,
|
||||||
config: { ...anchoredHeader.config, temperature: 0.2 },
|
config: { ...anchoredHeader.config, temperature: 0.2 },
|
||||||
}).baseline.kind).toBe('estimated')
|
}).baseline.kind).toBe('estimated')
|
||||||
expect(handle.measure(session, {
|
expect(service.measure(session, {
|
||||||
...anchoredHeader,
|
...anchoredHeader,
|
||||||
messagePrefix: [textMessage('prefix')],
|
messagePrefix: [textMessage('prefix')],
|
||||||
}).baseline.kind).toBe('estimated')
|
}).baseline.kind).toBe('estimated')
|
||||||
expect(handle.measure(session, {
|
expect(service.measure(session, {
|
||||||
...anchoredHeader,
|
...anchoredHeader,
|
||||||
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
|
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
|
||||||
}).baseline.kind).toBe('estimated')
|
}).baseline.kind).toBe('estimated')
|
||||||
@@ -357,7 +369,7 @@ describe('replay anchors and surface folds', () => {
|
|||||||
const session = new Session(SessionId('header-delta'))
|
const session = new Session(SessionId('header-delta'))
|
||||||
appendHeader(session, header('deepseek-v4-flash'))
|
appendHeader(session, header('deepseek-v4-flash'))
|
||||||
session.append('request/header-delta', { config: { model: 'deepseek-v4-pro' } })
|
session.append('request/header-delta', { config: { model: 'deepseek-v4-pro' } })
|
||||||
const result = meter().resolve('deepseek-v4-flash').measure(session)
|
const result = meter().measure(session)
|
||||||
expect(result.baseline.kind).toBe('estimated')
|
expect(result.baseline.kind).toBe('estimated')
|
||||||
expect(result.logRevision).toBe(2)
|
expect(result.logRevision).toBe(2)
|
||||||
})
|
})
|
||||||
@@ -374,28 +386,27 @@ describe('replay anchors and surface folds', () => {
|
|||||||
source: { kind: 'user' },
|
source: { kind: 'user' },
|
||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
const seeded = new Session(SessionId('surface-seeded'), original.events)
|
const seeded = new Session(SessionId('surface-seeded'), original.events)
|
||||||
const handle = service.resolve('deepseek-v4-flash')
|
const before = service.measure(seeded)
|
||||||
const before = handle.measureSurface(seeded)
|
|
||||||
const beforeScalar = handle.measure(seeded)
|
|
||||||
expect(before.nodes).toHaveLength(2)
|
expect(before.nodes).toHaveLength(2)
|
||||||
expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0)
|
expect(before.surfaceDeltaTokens).toBeGreaterThan(0)
|
||||||
|
expectSurfaceTotal(before)
|
||||||
|
|
||||||
const first = seeded.surface.nodes[0]!.seq
|
const first = seeded.surface.nodes[0]!.seq
|
||||||
seeded.append('user/message', {
|
seeded.append('user/message', {
|
||||||
content: [{ type: 'text', text: 'replacement' }],
|
content: [{ type: 'text', text: 'replacement' }],
|
||||||
source: { kind: 'plugin', plugin: 'test' },
|
source: { kind: 'plugin', plugin: 'test' },
|
||||||
}, { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] })
|
}, { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] })
|
||||||
const after = handle.measureSurface(seeded)
|
const after = service.measure(seeded)
|
||||||
const afterScalar = handle.measure(seeded)
|
|
||||||
expect(after.nodes).toHaveLength(2)
|
expect(after.nodes).toHaveLength(2)
|
||||||
expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1)
|
expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1)
|
||||||
expect(after.logRevision).toBe(seeded.events.length)
|
expect(after.logRevision).toBe(seeded.events.length)
|
||||||
expect(Object.isFrozen(after.nodes)).toBe(true)
|
expect(Object.isFrozen(after.nodes)).toBe(true)
|
||||||
expect(Object.isFrozen(after.nodes[0])).toBe(true)
|
expect(Object.isFrozen(after.nodes[0])).toBe(true)
|
||||||
expect(afterScalar.surfaceDeltaTokens).toBeLessThan(0)
|
expect(after.surfaceDeltaTokens).toBeLessThan(0)
|
||||||
|
expectSurfaceTotal(after)
|
||||||
expect(before.nodes).toHaveLength(2)
|
expect(before.nodes).toHaveLength(2)
|
||||||
expect(before.logRevision).toBe(original.events.length)
|
expect(before.logRevision).toBe(original.events.length)
|
||||||
expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0)
|
expect(before.surfaceDeltaTokens).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prices an empty assistant surface anchor as zero', () => {
|
it('prices an empty assistant surface anchor as zero', () => {
|
||||||
@@ -405,26 +416,27 @@ describe('replay anchors and surface folds', () => {
|
|||||||
durableText: '',
|
durableText: '',
|
||||||
provenance: 'empty',
|
provenance: 'empty',
|
||||||
})
|
})
|
||||||
const surface = meter().resolve('deepseek-v4-flash').measureSurface(session)
|
const measurement = meter().measure(session)
|
||||||
const assistant = session.events.find(event => event.type === 'assistant/message')!
|
const assistant = session.events.find(event => event.type === 'assistant/message')!
|
||||||
expect(surface.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }])
|
expect(measurement.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }])
|
||||||
expect(surface.totalTokens).toBe(0)
|
expect(measurement.surfaceTokens).toBe(0)
|
||||||
|
expectSurfaceTotal(measurement)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('malformed replay and listener lifecycle', () => {
|
describe('malformed replay and listener lifecycle', () => {
|
||||||
function expectRepeatedFailure(handle: ModelTokenMeter, session: Session, pattern: RegExp): void {
|
function expectRepeatedFailure(service: TokenMeterService, session: Session, pattern: RegExp): void {
|
||||||
expect(() => handle.measure(session)).toThrow(pattern)
|
expect(() => service.measure(session)).toThrow(pattern)
|
||||||
expect(() => handle.measure(session)).toThrow(pattern)
|
expect(() => service.measure(session)).toThrow(pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
it('rejects a header delta before any snapshot transactionally', () => {
|
it('rejects a header delta before any snapshot transactionally', () => {
|
||||||
const session = new Session(SessionId('bad-delta'))
|
const session = new Session(SessionId('bad-delta'))
|
||||||
session.append('request/header-delta', { config: { model: 'deepseek-v4-flash' } })
|
session.append('request/header-delta', { config: { model: 'deepseek-v4-flash' } })
|
||||||
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no preceding header/)
|
expectRepeatedFailure(meter(), session, /no preceding header/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a matching-model assistant without its step boundary transactionally', () => {
|
it('rejects an assistant without its step boundary transactionally', () => {
|
||||||
const session = new Session(SessionId('bad-step'))
|
const session = new Session(SessionId('bad-step'))
|
||||||
appendHeader(session, header('deepseek-v4-flash'))
|
appendHeader(session, header('deepseek-v4-flash'))
|
||||||
session.append('assistant/message', {
|
session.append('assistant/message', {
|
||||||
@@ -432,7 +444,7 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
step: 1,
|
step: 1,
|
||||||
content: [{ type: 'text', text: 'bad' }],
|
content: [{ type: 'text', text: 'bad' }],
|
||||||
}, { surfaceOp: 'append', sourceEventSeqs: [] })
|
}, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||||
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no matching step\/start/)
|
expectRepeatedFailure(meter(), session, /no matching step\/start/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears completed step boundaries and rejects overlapping or late step events', () => {
|
it('clears completed step boundaries and rejects overlapping or late step events', () => {
|
||||||
@@ -440,7 +452,7 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
overlapping.append('step/start', { turn: 1, step: 1 })
|
overlapping.append('step/start', { turn: 1, step: 1 })
|
||||||
overlapping.append('step/start', { turn: 1, step: 2 })
|
overlapping.append('step/start', { turn: 1, step: 2 })
|
||||||
expectRepeatedFailure(
|
expectRepeatedFailure(
|
||||||
meter().resolve('deepseek-v4-flash'),
|
meter(),
|
||||||
overlapping,
|
overlapping,
|
||||||
/arrived before turn 1\/step 1 ended/,
|
/arrived before turn 1\/step 1 ended/,
|
||||||
)
|
)
|
||||||
@@ -455,7 +467,7 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
content: [],
|
content: [],
|
||||||
}, { surfaceOp: 'append', sourceEventSeqs: [] })
|
}, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||||
expectRepeatedFailure(
|
expectRepeatedFailure(
|
||||||
meter().resolve('deepseek-v4-flash'),
|
meter(),
|
||||||
late,
|
late,
|
||||||
/no matching step\/start/,
|
/no matching step\/start/,
|
||||||
)
|
)
|
||||||
@@ -464,7 +476,7 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
mismatchedEnd.append('step/start', { turn: 1, step: 1 })
|
mismatchedEnd.append('step/start', { turn: 1, step: 1 })
|
||||||
mismatchedEnd.append('step/end', { turn: 1, step: 2 })
|
mismatchedEnd.append('step/end', { turn: 1, step: 2 })
|
||||||
expectRepeatedFailure(
|
expectRepeatedFailure(
|
||||||
meter().resolve('deepseek-v4-flash'),
|
meter(),
|
||||||
mismatchedEnd,
|
mismatchedEnd,
|
||||||
/step\/end .* no matching step\/start/,
|
/step\/end .* no matching step\/start/,
|
||||||
)
|
)
|
||||||
@@ -509,7 +521,7 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
content: [{ type: 'text', text: 'bad' }],
|
content: [{ type: 'text', text: 'bad' }],
|
||||||
usage: { inputTokens: 1, outputTokens: 1 },
|
usage: { inputTokens: 1, outputTokens: 1 },
|
||||||
}, { surfaceOp: 'append', sourceEventSeqs })
|
}, { surfaceOp: 'append', sourceEventSeqs })
|
||||||
expect(() => meter().resolve('deepseek-v4-flash').measure(session)).toThrow(testCase.pattern)
|
expect(() => meter().measure(session)).toThrow(testCase.pattern)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -528,7 +540,7 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
content: [],
|
content: [],
|
||||||
usage: { inputTokens: 1, outputTokens: 0 },
|
usage: { inputTokens: 1, outputTokens: 0 },
|
||||||
}, { surfaceOp: 'append', sourceEventSeqs: [source, source] })
|
}, { surfaceOp: 'append', sourceEventSeqs: [source, source] })
|
||||||
expect(() => meter().resolve('deepseek-v4-flash').measure(duplicate)).toThrow(/repeats source seq/)
|
expect(() => meter().measure(duplicate)).toThrow(/repeats source seq/)
|
||||||
|
|
||||||
const future = new Session(SessionId('future-source'))
|
const future = new Session(SessionId('future-source'))
|
||||||
future.append('step/start', { turn: 1, step: 1 })
|
future.append('step/start', { turn: 1, step: 1 })
|
||||||
@@ -539,7 +551,7 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
content: [],
|
content: [],
|
||||||
usage: { inputTokens: 1, outputTokens: 0 },
|
usage: { inputTokens: 1, outputTokens: 0 },
|
||||||
}, { surfaceOp: 'append', sourceEventSeqs: [99] })
|
}, { surfaceOp: 'append', sourceEventSeqs: [99] })
|
||||||
expect(() => meter().resolve('deepseek-v4-flash').measure(future)).toThrow(/is not earlier/)
|
expect(() => meter().measure(future)).toThrow(/is not earlier/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not partially apply a malformed assistant replacement', () => {
|
it('does not partially apply a malformed assistant replacement', () => {
|
||||||
@@ -556,7 +568,7 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
content: [{ type: 'text', text: 'replacement' }],
|
content: [{ type: 'text', text: 'replacement' }],
|
||||||
}, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] })
|
}, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] })
|
||||||
expectRepeatedFailure(
|
expectRepeatedFailure(
|
||||||
meter().resolve('deepseek-v4-flash'),
|
meter(),
|
||||||
session,
|
session,
|
||||||
/no matching step\/start/,
|
/no matching step\/start/,
|
||||||
)
|
)
|
||||||
@@ -572,32 +584,32 @@ describe('malformed replay and listener lifecycle', () => {
|
|||||||
content: [{ type: 'text', text: 'bad' }],
|
content: [{ type: 'text', text: 'bad' }],
|
||||||
source: { kind: 'user' },
|
source: { kind: 'user' },
|
||||||
}, { surfaceOp: { op: 'replace', start: 99, end: 99 }, sourceEventSeqs: [0] })
|
}, { surfaceOp: { op: 'replace', start: 99, end: 99 }, sourceEventSeqs: [0] })
|
||||||
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /invalid current range/)
|
expectRepeatedFailure(meter(), session, /invalid current range/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles earlier-reader catch-up, eager observation, and service reload', async () => {
|
it('handles earlier-reader catch-up, eager observation, and service reload', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
let handle: ModelTokenMeter | undefined
|
let activeMeter: TokenMeterService | undefined
|
||||||
const revisions: number[] = []
|
const revisions: number[] = []
|
||||||
ctx.on('session/event', (session) => {
|
ctx.on('session/event', (session) => {
|
||||||
if (handle !== undefined) revisions.push(handle.measure(session).logRevision)
|
if (activeMeter !== undefined) revisions.push(activeMeter.measure(session).logRevision)
|
||||||
})
|
})
|
||||||
const firstFiber = await ctx.plugin(TokenMeterService)
|
const firstFiber = await ctx.plugin(TokenMeterService)
|
||||||
handle = ctx.tokenMeter.resolve('deepseek-v4-flash')
|
activeMeter = ctx.tokenMeter
|
||||||
const session = ctx.sessions.create(SessionId('listener-order'))
|
const session = ctx.sessions.create(SessionId('listener-order'))
|
||||||
handle.measure(session)
|
activeMeter.measure(session)
|
||||||
session.append('user/message', {
|
session.append('user/message', {
|
||||||
content: [{ type: 'text', text: 'one' }],
|
content: [{ type: 'text', text: 'one' }],
|
||||||
source: { kind: 'user' },
|
source: { kind: 'user' },
|
||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
expect(revisions).toEqual([1])
|
expect(revisions).toEqual([1])
|
||||||
expect(handle.measure(session).logRevision).toBe(1)
|
expect(activeMeter.measure(session).logRevision).toBe(1)
|
||||||
|
|
||||||
await firstFiber.dispose()
|
await firstFiber.dispose()
|
||||||
const secondFiber = await ctx.plugin(TokenMeterService)
|
const secondFiber = await ctx.plugin(TokenMeterService)
|
||||||
handle = ctx.tokenMeter.resolve('deepseek-v4-flash')
|
activeMeter = ctx.tokenMeter
|
||||||
expect(handle.measure(session).logRevision).toBe(1)
|
expect(activeMeter.measure(session).logRevision).toBe(1)
|
||||||
await secondFiber.dispose()
|
await secondFiber.dispose()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
|||||||
title: 'Replay token measurement',
|
title: 'Replay token measurement',
|
||||||
mode: 'core',
|
mode: 'core',
|
||||||
consumers: ['compact-basic'],
|
consumers: ['compact-basic'],
|
||||||
note: 'Owns isolated per-model/session replay folds; pressure consumers share immutable revisioned measurements.',
|
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sessions',
|
key: 'sessions',
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
|
|
||||||
{ "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" },
|
{ "doc": "docs/core-data-structures/token-meter.md", "symbol": "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": "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": "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": "EpochHeader", "source": "packages/core/session/src/types.ts" },
|
||||||
|
|||||||
Reference in New Issue
Block a user