feat(llm): add replay token metering (PR2 round 1)

This commit is contained in:
Hypatia May
2026-07-15 14:47:29 +08:00
parent c9efdf68f9
commit f038780ff6
61 changed files with 3393 additions and 2369 deletions

View File

@@ -5,7 +5,7 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement
| Package | Role | ctx key |
|---|---|---|
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
| `compact-basic/` | A backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement is a reusable LLM-family service rather than a `CompactService` method; a template- or model-backed compactor can replace `compact-basic` without changing the meter or callers.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-compact-basic
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a chars-per-token heuristic (the `charsPerToken` config, default 4), token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`).
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`).
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
@@ -8,7 +8,7 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state.
- **Measurement** — the effective conversation model's `ModelTokenMeter` prices the provisional request envelope and current surface at one consumed-log revision. The current prompt and prefix override their logged values; the pre-step boundary reuses logged tools and call config.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
@@ -16,41 +16,34 @@ This backend owns the compaction policy:
- **Lifecycle** — `compactRegion()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged.
`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly.
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
## Config (`BasicCompactConfig`)
Every knob is **required** except `auto` — there is no concrete data yet to justify default thresholds/budgets, so a consumer states each value explicitly rather than inherit a guessed default. `auto` alone defaults to `true`.
Every common setting is optional. Every model known to `ctx.tokenMeter` receives the default compact policy lazily; named overrides merge only the fields supplied and must name a configured meter profile.
| Key | Required | Meaning |
|---|---|---|
| `contextWindow` | yes | Context window size in tokens. |
| `thresholdRatio` | yes | Compact when estimated usage exceeds this fraction of the window. |
| `retainTokens` | yes | Tokens of recent context to keep intact. |
| `summarizationModel` | yes | Model for summarization (`''` → use the agent's model). |
| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. |
| `models.<model>.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. |
| `summarizationModel` | no (default `''`) | Empty resolves the latest logged routed model, then `AgentOptions.model`. |
| `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. |
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
| `auto` | no (default `true`) | Register the `agent/pre-step` automatic listener. Set `false` for manual-only. |
## Usage
```ts
import type { Context } from 'cordis'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
export const name = 'compact-basic'
export const inject = ['llm']
export function apply(ctx: Context): void {
ctx.plugin(BasicCompactService, {
contextWindow: 128000,
thresholdRatio: 0.8,
retainTokens: 20480,
summarizationModel: '',
maxTokens: 8192,
compactionRetries: 1,
})
ctx.plugin(TokenMeterService)
ctx.plugin(BasicCompactService)
}
```
@@ -122,8 +115,8 @@ Rules:
## Known Limitations and Deferred Work
- **Token estimation is the chars/`charsPerToken` heuristic** — a marked TODO schedules replacing it with an exact count (a real tokenizer, or provider `usage` fed back) so thresholds track the model's actual budget.
- **`estimatePressure()` does not count the request's `tools` field** — pressure is underestimated by the size of the serialized tool schemas the request also carries.
- **Pre-step sees a provisional request envelope** — the current prompt and prefix are exact, but routing and tool changes made later in `agent/request` are not logged yet. A router-only agent with no provisional model skips that check.
- **Meter accuracy follows the selected profile** — missing provider usage falls back to the token meter's configured character density and structural overhead.
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
- **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds.
- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-compact-basic",
"description": "Basic compaction backend (chars-per-token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
"description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -26,9 +26,15 @@
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
@@ -36,6 +42,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,60 @@
/**
* Automatic pre-step pressure listener for compact-basic.
*
* @module @deepseek-ai/dsh-compact-basic/automatic
*/
import type { Context } from 'cordis'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import type { Message } from '@deepseek-ai/dsh-llm'
import {
TOKEN_METER_MODEL_UNCONFIGURED,
TokenMeterError,
} from '@deepseek-ai/dsh-token-meter'
import type { Agent } from '@deepseek-ai/dsh-agent'
interface AutomaticCompactor {
compactIfNeeded(
agent: Agent,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
signal: AbortSignal,
): Promise<CompactionResult | null>
}
/**
* Register the implementation-owned automatic compaction listener.
* @param ctx - context owning the listener effect and logger.
* @param service - compactor whose public methods remain dynamically dispatched.
*/
export function registerAutomaticCompaction(
ctx: Context,
service: AutomaticCompactor,
): void {
ctx.on('agent/pre-step', async (
agent: Agent,
_turn: number,
_step: number,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
signal: AbortSignal,
) => {
try {
const result = await service.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
if (result !== null) {
ctx.logger.info(
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes `
+ `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, `
+ `~${result.shadowedTokenCount} tokens)`,
)
}
} catch (error: unknown) {
// A named routed model without a meter profile is configuration failure,
// not an optional operational compaction miss.
if (error instanceof TokenMeterError
&& error.code === TOKEN_METER_MODEL_UNCONFIGURED) throw error
const message = error instanceof Error ? error.message : String(error)
ctx.logger.warn(`compaction failed: ${message}; proceeding with full history`)
}
})
}

View File

@@ -0,0 +1,117 @@
/**
* Runtime defaulting and per-model policy validation for compact-basic.
*
* @module @deepseek-ai/dsh-compact-basic/config
*/
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ModelTokenMeter, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type {
BasicCompactConfig,
ModelCompactConfig,
ResolvedConfig,
ResolvedModelCompactConfig,
} from './types.ts'
/** Default request-pressure fraction for every metered model. */
const DEFAULT_THRESHOLD_RATIO = 0.8
/** Default verbatim-tail fraction of a model's context window. */
const DEFAULT_RETAIN_RATIO = 0.16
/**
* Resolve common defaults and validate every named model override.
* @param config - raw compact-basic configuration.
* @param tokenMeter - owning meter service used to reject unknown override names.
* @returns a detached deeply immutable top-level configuration.
*/
export function resolveConfig(
config: BasicCompactConfig = {},
tokenMeter: TokenMeterService,
): ResolvedConfig {
const configuredModels: unknown = config.models
const models = configuredModels === undefined ? {} : configuredModels
if (typeof models !== 'object' || models === null || Array.isArray(models)) {
throw new Error('BasicCompactConfig: models must be an object')
}
const detachedModels: Record<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,
auto: true,
}, meter)
}
const resolved: ResolvedConfig = {
models: detachedModels,
summarizationModel: config.summarizationModel ?? '',
maxTokens: config.maxTokens ?? 8192,
compactionRetries: config.compactionRetries ?? 1,
auto: config.auto ?? true,
}
assertPositiveInteger('maxTokens', resolved.maxTokens)
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
if (typeof resolved.summarizationModel !== 'string') {
throw new Error('BasicCompactConfig: summarizationModel must be a string')
}
if (typeof resolved.auto !== 'boolean') {
throw new Error('BasicCompactConfig: auto must be a boolean')
}
return deepFreeze(structuredClone(resolved))
}
/**
* Resolve one effective model's default policy plus optional field overrides.
* @param config - validated compact-basic configuration.
* @param meter - effective model's token-meter handle and context capacity.
* @returns a detached immutable model policy.
*/
export function resolveModelConfig(
config: ResolvedConfig,
meter: ModelTokenMeter,
): ResolvedModelCompactConfig {
const override = config.models[meter.model]
const thresholdRatio = override?.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
const retainTokens = override?.retainTokens ?? Math.floor(meter.contextWindow * DEFAULT_RETAIN_RATIO)
assertRatio(`models.${meter.model}.thresholdRatio`, thresholdRatio)
assertNonNegativeInteger(`models.${meter.model}.retainTokens`, retainTokens)
const thresholdTokens = Math.floor(meter.contextWindow * thresholdRatio)
if (retainTokens >= thresholdTokens) {
throw new Error(
`BasicCompactConfig: models.${meter.model}.retainTokens (${retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
)
}
return deepFreeze({
model: meter.model,
contextWindow: meter.contextWindow,
thresholdRatio,
retainTokens,
})
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer`)
}
}
function assertNonNegativeInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer`)
}
}
function assertRatio(name: string, value: number): void {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1]`)
}
}

View File

@@ -1,286 +1,120 @@
/**
* Basic compaction backend. It estimates request pressure, retains a recent
* tool-balanced surface tail, summarizes the older head through a one-shot model
* call, and replaces that head with one checkpoint. Auto-compaction runs before
* every step so a growing turn can compact its earlier closed steps.
* Basic replay-aware compaction backend.
*
* @module @deepseek-ai/dsh-compact-basic
*/
import { Context } from 'cordis'
import { CompactService, renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import z from 'schemastery'
import { CompactService } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
import { resolveConfig } from './types.ts'
import { registerAutomaticCompaction } from './automatic.ts'
import { resolveConfig, resolveModelConfig } from './config.ts'
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
import { summarizeWithLlm } from './summarizer.ts'
import type {
BasicCompactConfig,
ResolvedConfig,
ResolvedModelCompactConfig,
} from './types.ts'
export type { BasicCompactConfig, ResolvedConfig } from './types.ts'
export { resolveConfig } from './types.ts'
export { resolveConfig, resolveModelConfig } from './config.ts'
export type {
BasicCompactConfig,
ModelCompactConfig,
ResolvedConfig,
ResolvedModelCompactConfig,
} from './types.ts'
/** Per-block structural overhead for JSON framing / type tag. */
const BLOCK_OVERHEAD = 4
/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
const ROLE_OVERHEAD = 4
/** Tags wrapping the structured summary inside the landed checkpoint node. */
const SUMMARY_OPEN_TAG = '<compacted-summary>'
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
/**
* Fixed summary structure for resumable checkpoints. A tagged prior checkpoint
* is merged with newer history instead of copied forward verbatim.
*/
const SUMMARIZE_SYSTEM_PROMPT = [
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
'',
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
'',
'## Primary Request and Intent',
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
'',
'## Key Technical Concepts',
'- [technologies, frameworks, patterns, and conventions in play]',
'',
'## Files and Code',
'- [exact path: why it matters, key changes or snippets]',
'',
'## Errors and Fixes',
'- [error: how it was resolved, plus any related user feedback]',
'',
'## Pending Tasks',
'- [explicitly requested work not yet completed]',
'',
'## Current Work',
'- [precisely what was in progress at this checkpoint]',
'',
'## Next Step',
'- [the single next action, directly in line with the most recent request, or "(none)"]',
'',
'## Critical Context',
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
'',
'Rules:',
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
'- Do NOT mention this summarization process or that the context was compacted.',
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
].join('\n')
/** Framing that makes a landed summary established context rather than a new request. */
const CHECKPOINT_PREAMBLE =
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
/**
* Map a terminal summary failure to an error. A max-token finish is rejected
* because committing an incomplete checkpoint would shadow the full history.
*/
function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
case 'error': {
const error = new Error(finish.message) as Error & { code?: string }
if (finish.code !== undefined) error.code = finish.code
return error
}
case 'aborted': {
const error = new Error('summarization stream aborted') as Error & { code?: string }
error.code = 'ABORTED'
return error
}
case 'max-tokens': {
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
error.code = 'MAX_TOKENS'
return error
}
default:
return undefined
}
/** Resolve the latest actual routed model, then the agent's configured fallback. */
function effectiveModel(agent: Agent): string | undefined {
return agent.session.requestHeader()?.config.model ?? agent.options.model
}
/**
* Basic, dependency-light compaction backend: estimates the surface's token
* footprint, summarizes the stale prefix through the model, and shadows it
* behind a durable checkpoint. Every threshold/budget knob is required config
* ({@link BasicCompactConfig}); the estimator's text density is the
* `charsPerToken` knob.
* Build the provisional pre-step request envelope. Prompt and prefix are exact;
* tools and non-model call config come from the latest logged request because
* later request middleware has not run yet.
*/
function provisionalHeader(
model: string,
session: Session,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
): EpochHeader {
const latest = session.requestHeader()
return canonicalHeader({
config: latest === undefined ? { model } : { ...latest.config, model },
...fullSystemPrompt.length === 0 ? {} : { system: fullSystemPrompt },
...latest?.tools === undefined ? {} : { tools: latest.tools },
...sessionPrefix.length === 0 ? {} : { messagePrefix: [...sessionPrefix] },
})
}
/**
* Dependency-light compaction backend using `ctx.tokenMeter` for pressure,
* retention, provenance, and summary-convergence pricing.
*
* `summarize()` is the sole subclass customization hook; the replay and durable
* mutation strategy stays fixed so every pricing decision uses one effective
* conversation-model meter.
*/
export class BasicCompactService extends CompactService {
static inject = ['llm']
static inject = ['llm', 'tokenMeter']
/** Resolved configuration (`auto` defaulted). */
static Config: z<BasicCompactConfig> = z.object({
models: z.dict(z.object({
thresholdRatio: z.number(),
retainTokens: z.number().step(1),
})),
summarizationModel: z.string().default(''),
maxTokens: z.number().step(1).min(1).default(8192),
compactionRetries: z.number().step(1).min(0).default(1),
auto: z.boolean().default(true),
})
/** Resolved and validated common configuration plus named partial overrides. */
readonly config: ResolvedConfig
constructor(ctx: Context, config: BasicCompactConfig) {
private readonly modelConfigs = new Map<string, ResolvedModelCompactConfig>()
constructor(ctx: Context, config: BasicCompactConfig = {}) {
super(ctx)
this.config = resolveConfig(config)
if (this.config.auto) {
// Check before every step so a single growing turn can compact earlier closed steps.
// This serial pre-step seam mutates the surface outside the pending step.
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => {
try {
const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
if (result) {
const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix)
ctx.logger.info(
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
`~${result.shadowedTokenCount} tokens) ` +
`${after} estimated tokens after compaction`,
)
}
} catch (error: unknown) {
// A failed compaction must not prevent the model call — the surface is
// untouched on failure, so the loop derives the full history and the
// call proceeds.
const msg = error instanceof Error ? error.message : String(error)
ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`)
}
})
}
}
// ---- Token estimation (overridable hooks) ----
// TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
// count — a real tokenizer, or the provider's post-response `usage` (input
// tokens) fed back as a correction — so threshold decisions match the
// model's actual budget.
/**
* Estimate the token count of content blocks — chars divided by the
* `charsPerToken` config, with per-block overhead. Override in a subclass to
* plug in a real tokenizer.
*
* @param blocks - the blocks to estimate; `tool-result` blocks recurse into
* their nested content, and unknown (merge-extended) types fall back to
* their JSON-stringified length.
* @returns the estimated token count.
*/
estimateContentTokens(blocks: readonly ContentBlock[]): number {
const { charsPerToken } = this.config
let tokens = 0
for (const block of blocks) {
switch (block.type) {
case 'text':
case 'reasoning':
tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
break
case 'tool-call':
tokens += Math.ceil(block.name.length / charsPerToken)
+ Math.ceil(block.arguments.length / charsPerToken)
+ BLOCK_OVERHEAD
break
case 'tool-result':
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
break
default:
// Unknown block types (merge-extensible ContentBlockMap):
// estimate conservatively via JSON stringify.
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
}
}
return tokens
this.config = resolveConfig(config, ctx.tokenMeter)
if (this.config.auto) registerAutomaticCompaction(ctx, this)
}
/**
* Estimate token count for a single session event. Returns 0 for non-message
* event types (boundaries, chunks, usage, errors, compact markers).
*
* @param event - any session event; only the message-bearing types carry
* content to count.
* @returns the estimated token count of the event's content, or 0 for a
* non-message event.
*/
estimateEventTokens(event: SessionEvent): number {
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'context/message':
case 'steering/message':
case 'tool/result':
return this.estimateContentTokens(event.data.content)
default:
return 0
}
}
/**
* Estimate total tokens across a list of messages plus optional system prompt.
*
* @param messages - the derived conversation messages; each adds a fixed
* role-framing overhead on top of its content estimate.
* @param systemPrompt - counted at chars / `charsPerToken` when provided.
* @returns the estimated token footprint of the whole request.
*/
estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
let total = 0
for (const msg of messages) {
total += this.estimateContentTokens(msg.content)
total += ROLE_OVERHEAD
}
if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
return total
}
/**
* Summarize through a direct one-shot `ctx.llm.stream()` call, not an agent
* step or `agent/request` dispatch. Failure finishes and truncated summaries
* reject; the signal is forwarded and only text reaches the checkpoint.
*
* @param text - plain-text rendering of the conversation region to condense.
* @param agent - supplies the fallback model and the session id stamped on
* the call; throws when neither it nor the config names a model.
* @param signal - optional abort signal, forwarded into the model call.
* @returns the text-only summary blocks plus the call envelope used
* (`model`, and `maxTokens` when the summarizer has a cap).
* Summarize a rendered region through a direct one-shot `ctx.llm.stream()`
* call. Override this sole hook for a template or remote summarizer.
* @param text - plain-text conversation region to condense.
* @param agent - supplies routed-model history, fallback model, and session id.
* @param signal - optional cancellation forwarded to the adapter.
* @returns safe text summary blocks and exact auxiliary-call provenance.
*/
async summarize(
text: string, agent: Agent, signal?: AbortSignal,
text: string,
agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
const assembler = new BlockAssembler()
const options: GenerateOptions = {
model: this.config.summarizationModel || agent.options.model || '',
messages: [{
role: 'user',
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
}],
system: SUMMARIZE_SYSTEM_PROMPT,
maxTokens: this.config.maxTokens,
sessionId: agent.session.id,
}
// exactOptionalPropertyTypes: only set `signal` when present — assigning
// `undefined` to an optional `signal?: AbortSignal` is a type error.
if (signal) options.signal = signal
if (!options.model) {
throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel or AgentOptions.model')
}
for await (const chunk of this.ctx.llm.stream(options)) {
assembler.push(chunk)
}
const error = finishError(assembler.finish)
if (error) throw error
const summary = this._textOnly(assembler.message().content)
if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) {
throw new Error('summarization produced no text summary content')
}
// config.maxTokens is required and validated positive, so this backend's
// envelope always carries the cap; the return type's optionality exists
// for overriding subclasses whose summarizer has none.
return { summary, model: options.model, maxTokens: this.config.maxTokens }
return summarizeWithLlm(this.ctx, this.config, text, agent, signal)
}
// ---- Core API (implements the abstract contract) ----
/**
* The sole pressure gate: count the next request's prefix, derived history,
* and system prompt. Above threshold, retain a recent tool-balanced tail and
* compact the head, reconsolidating any prior automatic checkpoint. Returns
* `null` when no safe or necessary range exists.
* Check replayed pressure for the provisional pre-step envelope and compact
* a tool-balanced head until it falls below the effective model threshold.
* A genuinely model-less router-first step skips this provisional check;
* naming an unconfigured model throws the token meter's typed error.
* @param agent - agent whose session and provisional model are measured.
* @param fullSystemPrompt - current assembled system prompt override.
* @param sessionPrefix - current request-only prefix override.
* @param signal - live step cancellation signal forwarded to summarization.
* @returns the latest compaction result, or `null` when no check/work applies.
*/
override async compactIfNeeded(
agent: Agent,
@@ -288,47 +122,51 @@ export class BasicCompactService extends CompactService {
sessionPrefix: readonly Message[],
signal: AbortSignal,
): Promise<CompactionResult | null> {
const session = agent.session
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
let result: CompactionResult | null = null
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
if (totalTokens < threshold) return result
const model = effectiveModel(agent)
if (model === undefined || model.length === 0) return null
const meter = this.ctx.tokenMeter.resolve(model)
const policy = this._modelConfig(meter)
const requestHeader = provisionalHeader(model, agent.session, fullSystemPrompt, sessionPrefix)
const threshold = Math.floor(policy.contextWindow * policy.thresholdRatio)
let measurement = meter.measure(agent.session, requestHeader)
if (measurement.totalTokens < threshold) return null
const range = this._compactableRange(session)
let result: CompactionResult | null = null
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
const surface = meter.measureSurface(agent.session)
if (surface.logRevision !== measurement.logRevision) {
throw new Error(
`compaction: pressure revision ${measurement.logRevision} does not match surface revision ${surface.logRevision}`,
)
}
const range = selectCompactableRange(agent.session, surface, policy.retainTokens)
if (range === null) {
/* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
if (result === null) return null
/* v8 ignore next -- paired with the ignored defensive branch above. */
/* v8 ignore next -- paired with the defensive post-success branch above. */
break
}
result = await this.compactRegion(session, range.start, range.end, agent, signal)
result = await this.compactRegion(agent.session, range.start, range.end, agent, signal)
measurement = meter.measure(agent.session, requestHeader)
if (measurement.totalTokens < threshold) return result
}
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
if (totalTokens < threshold) return result
throw new Error(
`compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
+ `(${totalTokens} estimated tokens >= threshold ${threshold})`,
+ `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`,
)
}
/**
* Estimated token pressure of the NEXT request: the session prefix
* (`EpochHeader.messagePrefix` — request-only messages the loop sends in
* front of the derived history, composed before the pre-step seam and
* handed to the gate), the derived history, and the system prompt.
* @param session - the session whose next request is being estimated.
* @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
* @param sessionPrefix - the instance's composed session prefix (counts toward pressure).
* @returns the estimated token total the next request will carry.
* Compact one inclusive positional surface range using the effective
* conversation model for all retention and shrink pricing.
* @param session - session whose surface is mutated.
* @param start - inclusive first surface-node seq.
* @param end - inclusive last surface-node seq.
* @param agent - agent used by the summarizer and model resolver.
* @param signal - optional summarization cancellation signal.
* @returns the successful durable compaction result.
*/
estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number {
return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
}
override async compactRegion(
session: Session,
start: number,
@@ -336,214 +174,26 @@ export class BasicCompactService extends CompactService {
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
// Resolve by surface position: a newer replacement seq may occupy an older slot.
const nodes = session.surface.nodes
const startIdx = nodes.findIndex(n => n.seq === start)
const endIdx = nodes.findIndex(n => n.seq === end)
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
if (startIdx > endIdx) {
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
}
// Both range edges must preserve assistant tool-call/result pairing.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const startNode = nodes[startIdx]!
if (!toolPairingBalancedBefore(session, startNode)) {
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const endNode = nodes[endIdx]!
if (!toolPairingBalancedAfter(session, endNode)) {
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
}
if (this._isCompactionInProgress(session)) {
throw new Error('compaction already in progress')
}
// Compaction's events (compact/* and the replacement user/message) must be turn-enclosed:
// the session-log contract rejects any plugin event appended outside an open turn.
const openTurn = this._openTurn(session)
if (openTurn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
}
// Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
// shadowed range is positional, so this is the set the replace op covers.
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq)
// --- Acquire lock ---
const startEvent = session.append('compact/start', { turn: openTurn })
try {
// --- Extract text and summarize ---
const text = renderTranscript(session.events, shadowedSeqs)
const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
// Estimate token count of the shadowed content for provenance.
let shadowedTokenCount = 0
for (const seq of shadowedSeqs) {
// seq comes from a surface node — always a valid log index by construction.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
shadowedTokenCount += this.estimateEventTokens(session.events[seq]!)
}
const framedSummary = this._frameSummary(summary)
const framedSummaryTokenCount = this.estimateContentTokens(framedSummary)
if (framedSummaryTokenCount >= shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
)
}
// --- Provenance record (log-only) ---
const summaryEvent = session.append('compact/summary', {
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
model,
...maxTokens !== undefined ? { maxTokens } : {},
})
// --- Surface replacement --- The user/message directly shadows all compacted surface
// nodes with a single replace op.
session.append('user/message', {
content: framedSummary,
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
// --- Release lock (log-only) ---
// Appended LAST so the lock brackets the WHOLE operation: a crash between
// compact/start and here leaves a detectable orphaned lock (a compact/start
// with no matching compact/end) rather than a compact/end that falsely
// claims compaction finished before the surface replacement landed.
const endEvent = session.append('compact/end', { turn: openTurn })
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
}
} catch (error: unknown) {
// Always release the lock — append compact/end with the error so a
// wedged lock is impossible.
const msg = error instanceof Error ? error.message : String(error)
session.append('compact/end', { turn: openTurn, error: msg })
throw error
const model = effectiveModel(agent)
if (model === undefined || model.length === 0) {
throw new Error('compactRegion: no routed or configured conversation model is available for token pricing')
}
const meter = this.ctx.tokenMeter.resolve(model)
this._modelConfig(meter)
return compactSurfaceRegion({
meter,
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
}, session, start, end, agent, signal)
}
// ---- Internal helpers ----
/**
* Frame the raw summary blocks into the content that lands on the surface:
* a checkpoint preamble (so a resuming model reads it as a checkpoint, not a
* fresh user request) followed by the summary wrapped in
* {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior
* checkpoint detectable in the transcript on the next compaction cycle, which
* triggers the merge rule in the summarization prompt. The raw, unframed
* `summary` is preserved separately on the `compact/summary` provenance event.
*/
private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
return [
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
...summary,
{ type: 'text', text: SUMMARY_CLOSE_TAG },
]
}
/**
* Whether a compaction is currently in progress for `session` — an unmatched `compact/start`
* (no later `compact/end`) WITHIN the current turn.
*/
private _isCompactionInProgress(session: Session): boolean {
const events = session.events
for (let i = events.length - 1; i >= 0; i--) {
// Index bounded by i >= 0 and i < events.length — never undefined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const e = events[i]!
if (e.type === 'compact/start') return true
if (e.type === 'compact/end') break
// A turn/end bounds the scan: anything before it belongs to a prior
// (closed) turn and cannot be an in-progress compaction of THIS turn.
if (e.type === 'turn/end') break
/** Resolve and memoize one lazy default/override model policy. */
private _modelConfig(meter: ModelTokenMeter): ResolvedModelCompactConfig {
let modelConfig = this.modelConfigs.get(meter.model)
if (modelConfig === undefined) {
modelConfig = resolveModelConfig(this.config, meter)
this.modelConfigs.set(meter.model, modelConfig)
}
return false
}
/** Resolve the next head-anchored compactable surface range, or `null`. */
private _compactableRange(session: Session): { start: number; end: number } | null {
const nodes = session.surface.nodes
if (nodes.length === 0) return null
const events = session.events
const retainBudget = this.config.retainTokens
// Walk tail→head summing per-node token estimates. `keepFromIdx` is the
// index of the OLDEST node we retain verbatim; everything strictly older
// (`[0, keepFromIdx - 1]`) is the compactable range.
let accumulated = 0
let keepFromIdx = nodes.length // nothing retained yet
for (let i = nodes.length - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = nodes[i]!
const event = events[node.seq]
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
if (event) accumulated += this.estimateEventTokens(event)
keepFromIdx = i
if (accumulated >= retainBudget) break
}
// The whole surface fits the retain budget — nothing to compact.
if (keepFromIdx === 0) return null
// Round the cutoff to a tool-pairing boundary: if the cut before `nodes[keepFromIdx]` is
// unbalanced (an unanswered tool-call sits before it — i.e. it is mid-step), extend the
// retained side head-ward until the cut is balanced, so the compacted range ends without
// splitting an assistant↔result pair.
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (toolPairingBalancedBefore(session, nodes[keepFromIdx]!)) break
keepFromIdx -= 1
}
if (keepFromIdx === 0) return null
// The compacted range is [head … keepFromIdx - 1], anchored at the head.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const firstSeq = nodes[0]!.seq
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cutoffSeq = nodes[keepFromIdx - 1]!.seq
return { start: firstSeq, end: cutoffSeq }
}
/** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */
private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
}
/**
* The turn number of the currently OPEN turn — a `turn/start` not yet
* followed by its `turn/end` — or `null` if the session has no open turn.
*
* Compaction's events must be enclosed in a turn, so scanning back from the
* tail: a `turn/start` means that turn is open (return it); a `turn/end` means
* the most recent turn already closed (return null). The whole compaction
* sequence (compact/start … compact/end) is stamped with this turn.
*/
private _openTurn(session: Session): number | null {
for (let i = session.events.length - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const e = session.events[i]!
if (e.type === 'turn/start') return e.data.turn
if (e.type === 'turn/end') return null
}
return null
return modelConfig
}
}

View File

@@ -0,0 +1,196 @@
/**
* Surface retention selection and the log-recorded compaction transaction.
*
* @module @deepseek-ai/dsh-compact-basic/region
*/
import {
renderTranscript,
toolPairingBalancedAfter,
toolPairingBalancedBefore,
} from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import type { ModelTokenMeter, TokenSurfaceMeasurement } from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { frameSummary } from './summarizer.ts'
import type { SummaryResult } from './summarizer.ts'
interface RegionDependencies {
readonly meter: ModelTokenMeter
summarize(text: string, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
}
/**
* Resolve the next head-anchored range while retaining a priced recent tail
* and never splitting an assistant tool-call/result pair.
* @param session - session supplying authoritative current surface positions.
* @param pricedSurface - same-revision surface measurement from the conversation meter.
* @param retainTokens - minimum recent tail budget retained verbatim.
* @returns the inclusive positional seq range to compact, or `null`.
*/
export function selectCompactableRange(
session: Session,
pricedSurface: TokenSurfaceMeasurement,
retainTokens: number,
): { start: number; end: number } | null {
const pricedNodes = pricedSurface.nodes
if (pricedNodes.length === 0) return null
const surfaceNodes = session.surface.nodes
if (surfaceNodes.length !== pricedNodes.length
|| surfaceNodes.some((node, index) => node.seq !== pricedNodes[index]?.seq)) {
throw new Error('compaction: token-meter surface does not match the current session surface')
}
let accumulated = 0
let keepFromIdx = pricedNodes.length
for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
accumulated += pricedNodes[index]!.tokens
keepFromIdx = index
if (accumulated >= retainTokens) break
}
if (keepFromIdx === 0) return null
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
keepFromIdx -= 1
}
if (keepFromIdx === 0) return null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const first = surfaceNodes[0]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cutoff = surfaceNodes[keepFromIdx - 1]!
return { start: first.seq, end: cutoff.seq }
}
/**
* Validate and compact one positional surface span.
* @param dependencies - conversation meter and dynamically dispatched summarizer hook.
* @param session - session whose surface is mutated.
* @param start - inclusive first surface-node seq.
* @param end - inclusive last surface-node seq.
* @param agent - agent used by the summarizer.
* @param signal - optional summarization cancellation signal.
* @returns the successful durable compaction result.
*/
export async function compactSurfaceRegion(
dependencies: RegionDependencies,
session: Session,
start: number,
end: number,
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
const nodes = session.surface.nodes
const startIdx = nodes.findIndex(node => node.seq === start)
const endIdx = nodes.findIndex(node => node.seq === end)
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
if (startIdx > endIdx) {
throw new Error(
`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) {
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) {
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
}
const tail = inspectTurnTail(session.events)
if (tail.compactionInProgress) throw new Error('compaction already in progress')
if (tail.turn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
}
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(node => node.seq)
const startEvent = session.append('compact/start', { turn: tail.turn })
try {
// Capture after the lock event so any later durable append, including a
// log-only one, invalidates the async selection before replacement.
const lockedSurface = dependencies.meter.measureSurface(session)
const selected = lockedSurface.nodes.slice(startIdx, endIdx + 1)
if (selected.length !== shadowedSeqs.length
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
throw new Error('compaction: selected surface changed before summarization began')
}
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
const text = renderTranscript(session.events, shadowedSeqs)
const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal)
const currentSurface = dependencies.meter.measureSurface(session)
if (currentSurface.logRevision !== lockedSurface.logRevision) {
throw new Error('compaction: session log changed during summarization')
}
const framedSummary = frameSummary(summary)
const framedSummaryTokenCount = dependencies.meter.estimateMessage({
role: 'user',
content: framedSummary,
})
if (framedSummaryTokenCount >= shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
)
}
const summaryEvent = session.append('compact/summary', {
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
model,
...maxTokens === undefined ? {} : { maxTokens },
})
session.append('user/message', {
content: framedSummary,
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
const endEvent = session.append('compact/end', { turn: tail.turn })
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
session.append('compact/end', { turn: tail.turn, error: message })
throw error
}
}
/** Inspect the current turn boundary and latest compaction bracket once. */
function inspectTurnTail(
events: readonly SessionEvent[],
): { turn: number | null; compactionInProgress: boolean } {
let compactionInProgress = false
let compactionStateKnown = false
for (let index = events.length - 1; index >= 0; index -= 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = events[index]!
if (!compactionStateKnown) {
if (event.type === 'compact/start') {
compactionInProgress = true
compactionStateKnown = true
} else if (event.type === 'compact/end') {
compactionStateKnown = true
}
}
if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress }
if (event.type === 'turn/end') return { turn: null, compactionInProgress }
}
return { turn: null, compactionInProgress }
}

View File

@@ -0,0 +1,153 @@
/**
* Default one-shot summarization and durable checkpoint framing.
*
* @module @deepseek-ai/dsh-compact-basic/summarizer
*/
import type { Context } from 'cordis'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ResolvedConfig } from './types.ts'
/** Tags wrapping the structured summary inside the landed checkpoint node. */
const SUMMARY_OPEN_TAG = '<compacted-summary>'
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
/** Fixed structure required from the auxiliary summarization call. */
const SUMMARIZE_SYSTEM_PROMPT = [
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
'',
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
'',
'## Primary Request and Intent',
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
'',
'## Key Technical Concepts',
'- [technologies, frameworks, patterns, and conventions in play]',
'',
'## Files and Code',
'- [exact path: why it matters, key changes or snippets]',
'',
'## Errors and Fixes',
'- [error: how it was resolved, plus any related user feedback]',
'',
'## Pending Tasks',
'- [explicitly requested work not yet completed]',
'',
'## Current Work',
'- [precisely what was in progress at this checkpoint]',
'',
'## Next Step',
'- [the single next action, directly in line with the most recent request, or "(none)"]',
'',
'## Critical Context',
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
'',
'Rules:',
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
'- Do NOT mention this summarization process or that the context was compacted.',
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
].join('\n')
/** Framing that makes the replacement user message established context. */
const CHECKPOINT_PREAMBLE =
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
export interface SummaryResult {
summary: ContentBlock[]
model: string
maxTokens?: number
}
/**
* Run the default direct `ctx.llm.stream()` summarization call.
* @param ctx - context providing the LLM service.
* @param config - resolved backend configuration.
* @param text - rendered transcript region to summarize.
* @param agent - supplies routed-model history, fallback model, and session id.
* @param signal - optional cancellation forwarded to the adapter.
* @returns safe text-only summary blocks and exact call provenance.
*/
export async function summarizeWithLlm(
ctx: Context,
config: ResolvedConfig,
text: string,
agent: Agent,
signal?: AbortSignal,
): Promise<SummaryResult> {
const latestModel = agent.session.requestHeader()?.config.model
const model = config.summarizationModel || latestModel || agent.options.model || ''
if (model.length === 0) {
throw new Error(
'no model available for summarization: set BasicCompactConfig.summarizationModel, route one request, or set AgentOptions.model',
)
}
const assembler = new BlockAssembler()
const options: GenerateOptions = {
model,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
}],
system: SUMMARIZE_SYSTEM_PROMPT,
maxTokens: config.maxTokens,
sessionId: agent.session.id,
...signal === undefined ? {} : { signal },
}
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
const error = finishError(assembler.finish)
if (error !== undefined) throw error
const summary = textOnly(assembler.message().content)
if (!summary.some(block => block.text.trim().length > 0)) {
throw new Error('summarization produced no text summary content')
}
return { summary, model, maxTokens: config.maxTokens }
}
/**
* Wrap raw summary blocks in the durable checkpoint framing.
* @param summary - safe text-only model output.
* @returns content for the synthesized replacement user message.
*/
export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
return [
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
...summary,
{ type: 'text', text: SUMMARY_CLOSE_TAG },
]
}
/** Map a terminal summarization finish to its fail-closed error. */
function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
case 'error': {
const error = new Error(finish.message) as Error & { code?: string }
if (finish.code !== undefined) error.code = finish.code
return error
}
case 'aborted': {
const error = new Error('summarization stream aborted') as Error & { code?: string }
error.code = 'ABORTED'
return error
}
case 'max-tokens': {
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
error.code = 'MAX_TOKENS'
return error
}
default:
return undefined
}
}
/** Keep only text blocks before synthesizing a user message. */
function textOnly(
blocks: readonly ContentBlock[],
): Array<Extract<ContentBlock, { type: 'text' }>> {
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
}

View File

@@ -1,94 +1,44 @@
/**
* Configuration vocabulary for the basic compaction backend.
*
* Every tunable lives here, in the implementation — the abstract contract
* (`@deepseek-ai/dsh-compact`) carries no config, because thresholds and
* retention policy are HOW decisions a different backend would make
* differently.
* Configuration vocabulary for the replay-aware basic compaction backend.
*
* @module @deepseek-ai/dsh-compact-basic/types
*/
/**
* Backend configuration. Every knob is REQUIRED except `auto` and
* `charsPerToken`: there is no concrete data yet to justify default
* thresholds/budgets, so a consumer must state each value explicitly rather
* than inherit a guessed default. `auto` alone defaults to `true`
* (auto-compaction is the intended posture), and `charsPerToken` defaults to
* the English-text heuristic its estimator was calibrated on.
*/
/** Optional pressure and retention policy for one metered model. */
export interface ModelCompactConfig {
/** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */
thresholdRatio?: number
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
retainTokens?: number
}
/** Basic compaction configuration; every common field has a deployment default. */
export interface BasicCompactConfig {
/** Context window size in tokens. */
contextWindow: number
/** Compact when estimated token usage exceeds this fraction of context window. */
thresholdRatio: number
/** Number of tokens of recent context to retain during compaction. */
retainTokens: number
/** Model to use for summarization (`''` — uses the agent's model). */
summarizationModel: string
/** Provider generation cap for the summarization call. */
maxTokens: number
/** Extra compaction attempts when the first compacted surface is still over threshold. */
compactionRetries: number
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
/** Field-wise pressure/retention overrides keyed by configured token-meter model name. */
models?: Record<string, ModelCompactConfig>
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
summarizationModel?: string
/** Provider generation cap for summarization. Defaults to `8192`. */
maxTokens?: number
/** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */
compactionRetries?: number
/** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */
auto?: boolean
/**
* Text density for the token estimator: estimated tokens = chars /
* `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
* deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
* the default UNDERestimates several-fold and compaction fires far too late.
* May be fractional.
*/
charsPerToken?: number
}
/** Resolved config with `auto` and `charsPerToken` defaulted. */
export type ResolvedConfig = Required<BasicCompactConfig>
/**
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
*
* @param config - the raw, unresolved backend config.
* @returns the validated config with `auto` and `charsPerToken` defaulted.
*/
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }
assertPositiveInteger('contextWindow', resolved.contextWindow)
assertRatio('thresholdRatio', resolved.thresholdRatio)
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
assertPositiveInteger('maxTokens', resolved.maxTokens)
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
assertPositiveFinite('charsPerToken', resolved.charsPerToken)
if (typeof resolved.summarizationModel !== 'string') {
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
}
if (typeof resolved.auto !== 'boolean') {
throw new Error('BasicCompactConfig: auto must be a boolean.')
}
return resolved
/** Validated top-level defaults plus detached per-model partial overrides. */
export interface ResolvedConfig {
readonly models: Readonly<Record<string, Readonly<ModelCompactConfig>>>
readonly summarizationModel: string
readonly maxTokens: number
readonly compactionRetries: number
readonly auto: boolean
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer.`)
}
}
function assertNonNegativeInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer.`)
}
}
function assertPositiveFinite(name: string, value: number): void {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`)
}
}
function assertRatio(name: string, value: number): void {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)
}
/** Fully resolved pressure/retention policy for one effective model. */
export interface ResolvedModelCompactConfig {
readonly model: string
readonly contextWindow: number
readonly thresholdRatio: number
readonly retainTokens: number
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
@@ -20,13 +21,7 @@ import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
* surface-position semantics rather than raw-log scanning.
*/
const TOKENS_PER_BLOCK = 10
class ReproCompactService extends BasicCompactService {
override estimateContentTokens(blocks: readonly ContentBlock[]): number {
return blocks.length * TOKENS_PER_BLOCK
}
override async summarize(): Promise<{ summary: ContentBlock[]; model: string }> {
return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], model: 'stub' }
}
@@ -67,6 +62,9 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, {
models: { mock: { contextWindow: 64, charsPerToken: 1_000 } },
})
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
ctx.tools.register(defineTool({
name: 'work',
@@ -80,9 +78,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
// fires within the runaway turn.
const compact = new ReproCompactService(ctx, {
auto: true,
contextWindow: 64,
thresholdRatio: 0.5,
retainTokens: 20,
models: { mock: { thresholdRatio: 0.5, retainTokens: 20 } },
summarizationModel: '',
maxTokens: 8192,
compactionRetries: 1,

View File

@@ -0,0 +1,66 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('real Loader composition', () => {
it('loads the zero-config token-meter then compact-basic YAML pair', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-token-meter'",
"- name: '@deepseek-ai/dsh-compact-basic'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<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()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(context.tokenMeter.resolve('deepseek-v4-flash')).toMatchObject({
contextWindow: 128_000,
charsPerToken: 4,
})
expect(context.get('compact')).toBeInstanceOf(BasicCompactService)
})
})

View File

@@ -8,7 +8,9 @@
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../llm/llm" },
{ "path": "../../llm/token-meter" },
{ "path": "../../core/session" },
{ "path": "../../core/agent" },
{ "path": "../compact" }

View File

@@ -7,14 +7,14 @@ This package is the interface tier of the compaction capability, split so each c
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
## Service API (`ctx.compact`)
Both methods are **abstract** — the backend owns the entire strategy (token estimation, retention policy, event sequencing, summarization).
Both methods are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
| Member | Semantics |
|---|---|
@@ -53,7 +53,7 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
## Implementing a backend
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. A tokenizer-, template-, or model-backed implementation can live as a sibling package without changing callers.
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
## Model Experience

View File

@@ -29,10 +29,11 @@ declare module 'cordis' {
}
/**
* Abstract compaction service. Implementations own token estimation, retention,
* and summarization, but a successful run must replace the selected surface span
* with one summary node and prevent concurrent compaction of the same session.
* Load one implementation per context as `ctx.compact`.
* Abstract compaction service. Implementations own trigger policy, retention,
* and summarization, and may consume a separate measurement service. A
* successful run replaces the selected surface span with one summary node and
* prevents concurrent compaction of the same session. Load one implementation
* per context as `ctx.compact`.
*/
export abstract class CompactService extends Service {
constructor(ctx: Context) {

View File

@@ -214,6 +214,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
],
},
{
key: 'tokenMeter',
summary: 'Concrete registry and replay owner for all configured model meters.',
methods: [
'resolve(model: string): ModelTokenMeter',
],
},
{
key: 'tools',
summary: 'Tool registry and execution pipeline.',
@@ -669,6 +676,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DiffResultView',
declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}',
},
{
name: 'EpochHeader',
declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n}',
},
{
name: 'FileDiff',
declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}',
@@ -737,6 +748,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n}',
},
{
name: 'LlmCallConfig',
declaration: 'export interface LlmCallConfig {\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
},
{
name: 'Message',
declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n}',
@@ -749,6 +764,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
},
{
name: 'ModelTokenMeter',
declaration: 'export interface ModelTokenMeter {\n readonly model: string;\n readonly contextWindow: number;\n readonly charsPerToken: number;\n measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement;\n measureSurface(session: Session): TokenSurfaceMeasurement;\n estimateMessage(message: Message): number;\n}',
},
{
name: 'OwnerToken',
declaration: 'export type OwnerToken = Branded<\'OwnerToken\'>;',
@@ -941,6 +960,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TodoItem',
declaration: 'export interface TodoItem {\n content: string;\n status: \'pending\' | \'in_progress\' | \'completed\';\n}',
},
{
name: 'TokenMeasurement',
declaration: 'export interface TokenMeasurement {\n readonly model: string;\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n}',
},
{
name: 'TokenMeasurementBaseline',
declaration: 'export type TokenMeasurementBaseline = {\n readonly kind: \'none\';\n readonly tokens: 0;\n} | {\n readonly kind: \'estimated\';\n readonly tokens: number;\n} | {\n readonly kind: \'usage\';\n readonly tokens: number;\n readonly usage: Readonly<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',
declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n}',
},
{
name: 'TokenUsage',
declaration: 'export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n}',

View File

@@ -50,6 +50,8 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re
The driver owns one agent for its lifetime. It records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures.
Every provider call that reaches a successful finish appends one `assistant/message` completion anchor after `agent/step-result`, including content-less calls and `max-tokens` finishes. The anchor records exact chunk provenance (`[]` for a stream with no chunks) and usage when available; empty content stays out of derived message history while those replay facts remain durable.
Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush.
### What belongs to plugins

View File

@@ -528,15 +528,14 @@ async function runStep(
if (assembler.finish.kind === 'max-tokens') {
let message: Message = withoutToolCalls(assembler.message())
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)))
// Preserve usage even when max-token truncation produced no content.
if (message.content.length > 0 || assembler.usage) {
// The finish chunk guarantees non-empty provenance here.
session.append(
'assistant/message',
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
}
// Every successful call records its completion anchor. Empty content is
// skipped by deriveMessages(), while exact chunk provenance lets replay
// distinguish a known empty provider stream from unrecorded provenance.
session.append(
'assistant/message',
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
return { hadToolCalls: false, finish: assembler.finish }
}
@@ -544,14 +543,14 @@ async function runStep(
let message: Message = assembler.message()
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
// Empty messages exist only to carry usage; omit empty provenance.
if (message.content.length > 0 || assembler.usage) {
session.append(
'assistant/message',
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
{ surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
)
}
// Every successful call records its completion anchor. A present empty
// source set means the provider stream was known to contain no chunks;
// omission remains the conservative legacy/unrecorded representation.
session.append(
'assistant/message',
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
// Tool execution stays sequential; recheck abort around each normalized result.
const toolCalls = message.content.filter(block => block.type === 'tool-call')

View File

@@ -1075,9 +1075,10 @@ describe('tool result call identity', () => {
})
})
describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
// Injected result content with no chunks must omit empty sourceEventSeqs.
describe('surface: assistant/message records exact empty provenance when no chunks streamed', () => {
it('a step-result listener injecting content over an empty stream records sourceEventSeqs []', async () => {
// The explicit empty source set distinguishes a known empty provider
// stream from legacy events whose provenance was not recorded.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
@@ -1094,7 +1095,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
expect(recorded.type).toBe('assistant/message')
expect(recorded.surfaceOp).toBe('append')
expect(recorded.sourceEventSeqs).toBeUndefined()
expect(recorded.sourceEventSeqs).toEqual([])
// The injected content reaches derived history.
expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected')
})

View File

@@ -717,10 +717,9 @@ describe('agent loop', () => {
})
})
it('appends no assistant/message for a max-tokens step with empty content and no usage', async () => {
// A max-tokens step truncated to a dropped tool call AND with no usage chunk has nothing to
// record: empty content and no accounting → no assistant/message (the empty-content host
// exists only to carry usage).
it('appends an empty completion anchor for a max-tokens step with no usage', async () => {
// The truncated tool call is dropped from durable content, while the
// successful provider call still needs an exact replay anchor.
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
@@ -744,14 +743,19 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'max-tokens' }])
expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
const assistant = agent.session.events.find(e => e.type === 'assistant/message')!
expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
turn: 1,
step: 1,
content: [],
})
expect(assistant.sourceEventSeqs?.length).toBeGreaterThan(0)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
})
it('appends no assistant/message for a normal stop finish with empty content and no usage', async () => {
// A clean `stop` finish that streamed nothing assembled (no blocks) and
// carried no usage chunk has nothing to record: the content-or-usage guard
// on the normal step path suppresses a pure trace-only empty assistant/message.
it('appends an empty completion anchor for a normal stop with no usage', async () => {
// A clean content-less call stays absent from derived messages but remains
// a durable successful-call boundary for replay consumers.
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -763,7 +767,13 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'completed' }])
expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
const assistant = agent.session.events.find(e => e.type === 'assistant/message')!
expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
turn: 1,
step: 1,
content: [],
})
expect(assistant.sourceEventSeqs?.length).toBe(1)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
})

View File

@@ -66,7 +66,7 @@ Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types
Every `SessionEvent` carries two optional top-level fields (structural metadata):
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node).
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means legacy or otherwise unrecorded provenance; other surface events require a non-empty list when this field is present.
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
### Metadata types (`types.ts`)

View File

@@ -331,6 +331,12 @@ export type SurfaceOp =
*/
export interface SurfaceIntent {
surfaceOp: SurfaceOp
/**
* Complete known provenance source set. `assistant/message` may use a
* present empty array for a known empty provider stream; omission means its
* provenance was not recorded. Other surface events require a non-empty set
* when this field is present.
*/
sourceEventSeqs?: number[]
}
@@ -359,7 +365,9 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
/**
* Seq numbers of events that are provenance sources of this event
* (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
* or the surface nodes shadowed by a compaction replace node).
* or the surface nodes shadowed by a compaction replace node). An
* `assistant/message` may carry a present empty array for a known empty
* provider stream; omission means unrecorded provenance.
*/
sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */

View File

@@ -5,7 +5,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a
| Package | Role | ctx key |
|---|---|---|
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `token-meter/` | Replay-aware, per-model request and surface token measurement | `ctx.tokenMeter` |
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
The interface lives at `llm/llm/`; adapters are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist.
The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist and the [replay token meter RFC](../../docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership.

View File

@@ -0,0 +1,57 @@
# @deepseek-ai/dsh-token-meter
Replay-aware token measurement through `ctx.tokenMeter`. The service binds one stable meter to each configured model and advances isolated per-model/per-session folds from the durable session log. Compaction consumes it today; other pressure-sensitive plugins can reuse the same accounting without depending on `CompactService`.
## Profiles and configuration
The built-in `deepseek-v4-flash` and `deepseek-v4-pro` profiles each use a 128,000-token context window and four characters per estimated token. `models` merges overrides field-by-field, so changing only density keeps the built-in window. A custom model requires `contextWindow`; its `charsPerToken` defaults to `4`.
| Key | Default | Contract |
|---|---:|---|
| `models.<built-in>.contextWindow` | `128000` | Positive integer provider 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.
## Measurement contract
`ctx.tokenMeter.resolve(model)` returns a `ModelTokenMeter` with three operations:
- `measure(session, requestHeader?)` returns scalar request pressure at one consumed-log revision.
- `measureSurface(session)` returns current surface nodes and their per-node prices at the same kind of revision.
- `estimateMessage(message)` prices one detached message under that profile.
Measurements are detached and deeply immutable. A caller that needs a consistent scalar/surface decision compares their `logRevision` values instead of copying the full history on every read.
The fold tracks request headers and deltas, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the handle's model and the canonical request envelope match the successful-call anchor. Otherwise the complete current envelope and surface are repriced under the requested model. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output.
## Composition
```yaml
- name: '@deepseek-ai/dsh-token-meter'
- name: '@deepseek-ai/dsh-compact-basic'
```
Both plugins have usable defaults for the bundled DeepSeek profiles. Custom deployments can override only the fields that differ:
```yaml
- name: '@deepseek-ai/dsh-token-meter'
config:
models:
deepseek-v4-flash:
charsPerToken: 2
local-model:
contextWindow: 32768
```
## Model Experience
Indirectly, through consumers such as `dsh-compact-basic`; the service itself adds no prompt, message, schema, tool, or model call.
## Known Limitations and Deferred Work
- **Heuristic density still needs maintenance** — message content without provider usage is priced by configured character density plus structural overhead, not an exact provider tokenizer. CJK-heavy or provider-specific formats may need profile overrides.
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, or call-config changes deliberately fall back to full heuristic repricing.
- **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-token-meter",
"description": "Replay-aware per-model token measurement service (ctx.tokenMeter) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,193 @@
/**
* Replay token-meter service with model-specific context capacity and pricing.
*
* @module @deepseek-ai/dsh-token-meter
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { ReplayModelTokenMeter } from './replay.ts'
import type { ModelTokenProfile } from './replay.ts'
import type {
ModelTokenMeter,
ModelTokenMeterConfig,
TokenMeterConfig,
} from './types.ts'
export type * from './types.ts'
/** Exact error code for resolving a model without a configured profile. */
export const TOKEN_METER_MODEL_UNCONFIGURED = 'TOKEN_METER_MODEL_UNCONFIGURED'
/** Exact error code for invalid token-meter configuration. */
export const TOKEN_METER_INVALID_CONFIG = 'TOKEN_METER_INVALID_CONFIG'
/** Closed machine-routable token-meter failure taxonomy. */
export type TokenMeterErrorCode =
| typeof TOKEN_METER_MODEL_UNCONFIGURED
| typeof TOKEN_METER_INVALID_CONFIG
/** Built-in DeepSeek model profiles available with zero configuration. */
const BUILTIN_TOKEN_PROFILES: Readonly<Record<string, Readonly<ModelTokenProfile>>> = deepFreeze({
'deepseek-v4-flash': {
model: 'deepseek-v4-flash',
contextWindow: 128_000,
charsPerToken: 4,
},
'deepseek-v4-pro': {
model: 'deepseek-v4-pro',
contextWindow: 128_000,
charsPerToken: 4,
},
})
/** Typed token-meter failure with the affected model preserved for callers. */
export class TokenMeterError extends HarnessError {
declare readonly code: TokenMeterErrorCode
/** Exact model name involved in this error, when applicable. */
readonly model: string | undefined
constructor(message: string, code: TokenMeterErrorCode, model?: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'TokenMeterError'
this.model = model
}
}
declare module 'cordis' {
interface Context {
tokenMeter: TokenMeterService
}
}
/** Validate and detach all configured model profiles. */
function resolveProfiles(config: TokenMeterConfig): readonly ModelTokenProfile[] {
const profiles = new Map<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 {
static Config: z<TokenMeterConfig> = z.object({
models: z.dict(z.object({
contextWindow: z.number(),
charsPerToken: z.number(),
})),
})
private readonly meters = new Map<string, ReplayModelTokenMeter>()
constructor(ctx: Context, config: TokenMeterConfig = {}) {
super(ctx, 'tokenMeter')
for (const profile of resolveProfiles(config)) {
this.meters.set(profile.model, new ReplayModelTokenMeter(profile))
}
// Readers catch up independently, while eager observation bounds ordinary
// read latency. A reader in an earlier listener consumes the new event;
// this listener then sees the same revision and performs no duplicate fold.
ctx.on('session/event', (session) => {
this._observe(session)
})
}
/**
* Resolve one stable model-bound replay handle.
* @param model - exact routed model name.
* @throws {@link TokenMeterError} with `TOKEN_METER_MODEL_UNCONFIGURED` when no profile exists.
* @returns the configured handle for this model.
*/
resolve(model: string): ModelTokenMeter {
const meter = this.meters.get(model)
if (meter === undefined) {
throw new TokenMeterError(
`token meter has no profile for model "${model}"`,
TOKEN_METER_MODEL_UNCONFIGURED,
model,
)
}
return meter
}
/** Advance every configured model's isolated replay fold. */
private _observe(session: Session): void {
for (const meter of this.meters.values()) meter.observeIfActive(session)
}
}
export default TokenMeterService

View File

@@ -0,0 +1,367 @@
/**
* Model-bound transactional replay of request headers, surface mutations, and
* successful-call token anchors.
*
* @module @deepseek-ai/dsh-token-meter/replay
*/
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session'
import { applyHeaderDelta, canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import type {
ModelTokenMeter,
TokenMeasurement,
TokenMeasurementBaseline,
TokenSurfaceMeasurement,
TokenSurfaceNode,
} from './types.ts'
/** Internal validated pricing profile. */
export interface ModelTokenProfile {
readonly model: string
readonly contextWindow: number
readonly charsPerToken: number
}
/** Per-block structural overhead for JSON framing and type tags. */
const BLOCK_OVERHEAD = 4
/** Role-field framing overhead added to every priced message. */
const ROLE_OVERHEAD = 4
interface UsageAnchor {
readonly header: EpochHeader
readonly surfaceTokens: number
readonly baseline: Exclude<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
}
}

View File

@@ -0,0 +1,101 @@
/**
* Public configuration and measurement vocabulary for replay token metering.
*
* @module @deepseek-ai/dsh-token-meter/types
*/
import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
/** Optional pricing fields for one configured model. */
export interface ModelTokenMeterConfig {
/** Provider context-window capacity in tokens. Required for a custom model. */
contextWindow?: number
/** Heuristic text density in characters per token. Defaults to `4`. */
charsPerToken?: number
}
/** Token-meter plugin configuration. */
export interface TokenMeterConfig {
/** Built-in field overrides and custom model profiles, keyed by routed model name. */
models?: Record<string, ModelTokenMeterConfig>
}
/** The baseline from which a signed surface delta produces current pressure. */
export type TokenMeasurementBaseline =
| { readonly kind: 'none'; readonly tokens: 0 }
| { readonly kind: 'estimated'; readonly tokens: number }
| { readonly kind: 'usage'; readonly tokens: number; readonly usage: Readonly<TokenUsage> }
/** Detached immutable scalar pressure at one consumed session-log revision. */
export interface TokenMeasurement {
/** Model profile used for every heuristic component. */
readonly model: string
/** Number of durable events consumed; equal to the next unread event seq. */
readonly logRevision: number
/** Provider or heuristic anchor used for this measurement. */
readonly baseline: TokenMeasurementBaseline
/** Signed repricing of current surface content relative to the baseline anchor. */
readonly surfaceDeltaTokens: number
/** Non-negative current request-and-response pressure. */
readonly totalTokens: number
}
/** One token-priced node in the current ordered session surface. */
export interface TokenSurfaceNode {
/** Durable sequence number of the surface event. */
readonly seq: number
/** Heuristic tokens for the exact message projected by this node. */
readonly tokens: number
}
/** Detached immutable priced surface at one consumed session-log revision. */
export interface TokenSurfaceMeasurement {
/** Model profile used to price every node. */
readonly model: string
/** Number of durable events consumed; equal to the next unread event seq. */
readonly logRevision: number
/** Total heuristic tokens across the current surface. */
readonly totalTokens: number
/** Current surface nodes in positional head-to-tail order. */
readonly nodes: readonly TokenSurfaceNode[]
}
/** A model-bound replay meter returned by {@link TokenMeterService.resolve}. */
export interface ModelTokenMeter {
/** Routed model name bound to this handle. */
readonly model: string
/** Provider context-window capacity in tokens. */
readonly contextWindow: number
/** Heuristic text density in characters per token. */
readonly charsPerToken: number
/**
* Measure current request pressure through the session's durable tail.
*
* Provider usage is reused only when its routed model and canonical request
* envelope match `requestHeader`; otherwise the complete envelope and
* surface are heuristically repriced for this handle's model.
*
* @param session - session to replay through its current durable tail.
* @param requestHeader - optional effective request envelope replacing the latest logged header.
* @returns a detached deeply immutable pressure measurement.
*/
measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement
/**
* Price the current surface for retention and replacement decisions.
*
* @param session - session to replay through its current durable tail.
* @returns a detached deeply immutable positional surface measurement.
*/
measureSurface(session: Session): TokenSurfaceMeasurement
/**
* Heuristically price one model-visible message.
*
* @param message - message to price without mutation.
* @returns content and role-framing tokens under this model profile.
*/
estimateMessage(message: Message): number
}

View File

@@ -0,0 +1,603 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
import type { EpochHeader } from '@deepseek-ai/dsh-session'
import TokenMeterService, {
TOKEN_METER_INVALID_CONFIG,
TOKEN_METER_MODEL_UNCONFIGURED,
TokenMeterError,
} from '@deepseek-ai/dsh-token-meter'
import type { ModelTokenMeter, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
function header(model: string, extras: Omit<EpochHeader, 'config'> = {}): EpochHeader {
return canonicalHeader({ config: { model }, ...extras })
}
function textMessage(text: string, role: Message['role'] = 'user'): Message {
return { role, content: [{ type: 'text', text }] }
}
function appendHeader(session: Session, value: EpochHeader): void {
session.append('request/header', { header: value, reason: 'initial' })
}
interface SuccessfulCallOptions {
turn?: number
step?: number
providerText?: string
durableText?: string
usage?: TokenUsage
provenance?: 'exact' | 'empty' | 'absent'
}
function appendSuccessfulCall(
session: Session,
value: EpochHeader,
options: SuccessfulCallOptions = {},
): void {
const turn = options.turn ?? 1
const step = options.step ?? 1
const providerText = options.providerText ?? 'provider answer'
const durableText = options.durableText ?? providerText
const provenance = options.provenance ?? 'exact'
session.append('step/start', { turn, step })
appendHeader(session, value)
const sources: number[] = []
if (provenance === 'exact') {
const chunks = [
{ type: 'block-start' as const, index: 0, blockType: 'text' as const },
{ type: 'text-delta' as const, index: 0, text: providerText },
{ type: 'block-end' as const, index: 0, block: { type: 'text' as const, text: providerText } },
...options.usage === undefined ? [] : [{ type: 'usage' as const, usage: options.usage }],
{ type: 'finish' as const, reason: { kind: 'stop' as const } },
]
for (const chunk of chunks) {
sources.push(session.append('assistant/chunk', { turn, step, chunk }).seq)
}
}
const intent = provenance === 'absent'
? { surfaceOp: 'append' as const }
: { surfaceOp: 'append' as const, sourceEventSeqs: provenance === 'empty' ? [] : sources }
session.append('assistant/message', {
turn,
step,
content: durableText.length === 0 ? [] : [{ type: 'text', text: durableText }],
...options.usage === undefined ? {} : { usage: options.usage },
}, intent)
session.append('step/end', { turn, step })
}
function meter(config: TokenMeterConfig = {}): TokenMeterService {
return new TokenMeterService(new Context(), config)
}
describe('TokenMeterService configuration and registration', () => {
it('provides immutable zero-config DeepSeek profiles', () => {
const service = meter()
expect(service.resolve('deepseek-v4-flash')).toMatchObject({
model: 'deepseek-v4-flash',
contextWindow: 128_000,
charsPerToken: 4,
})
expect(service.resolve('deepseek-v4-pro')).toMatchObject({
model: 'deepseek-v4-pro',
contextWindow: 128_000,
charsPerToken: 4,
})
})
it('merges built-in overrides field-wise and defaults custom density', () => {
const service = meter({
models: {
'deepseek-v4-flash': { charsPerToken: 2 },
custom: { contextWindow: 32_000 },
},
})
expect(service.resolve('deepseek-v4-flash')).toMatchObject({ contextWindow: 128_000, charsPerToken: 2 })
expect(service.resolve('deepseek-v4-pro')).toMatchObject({ contextWindow: 128_000, charsPerToken: 4 })
expect(service.resolve('custom')).toMatchObject({ contextWindow: 32_000, charsPerToken: 4 })
})
it('throws a typed exact-code error for unknown models', () => {
const service = meter()
let thrown: unknown
try {
service.resolve('unconfigured-model')
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBeInstanceOf(TokenMeterError)
expect(thrown).toMatchObject({
code: TOKEN_METER_MODEL_UNCONFIGURED,
model: 'unconfigured-model',
})
expect((thrown as Error).message).toContain('unconfigured-model')
})
it.each([
[{ models: null }, /models must be an object/],
[{ models: [] }, /models must be an object/],
[{ models: { custom: {} } }, /requires contextWindow/],
[{ models: { '': { contextWindow: 1 } } }, /must not be empty/],
[{ models: { custom: { contextWindow: 0 } } }, /positive integer/],
[{ models: { custom: { contextWindow: 1.5 } } }, /positive integer/],
[{ models: { custom: { contextWindow: 1, charsPerToken: 0 } } }, /positive finite/],
[{ models: { custom: { contextWindow: 1, charsPerToken: Number.NaN } } }, /positive finite/],
[{ models: { custom: null } }, /must be an object/],
[{ models: { custom: [] } }, /must be an object/],
] as unknown as Array<[TokenMeterConfig, RegExp]>)('rejects invalid profile config %#', (config, pattern) => {
let thrown: unknown
try {
meter(config)
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBeInstanceOf(TokenMeterError)
expect(thrown).toMatchObject({ code: TOKEN_METER_INVALID_CONFIG })
expect((thrown as Error).message).toMatch(pattern)
})
it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TokenMeterService)
expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeterService)
await fiber.dispose()
expect(ctx.get('tokenMeter')).toBeUndefined()
})
})
describe('ModelTokenMeter pricing', () => {
it('prices every built-in content shape and merge-extended blocks', () => {
const handle = meter({ models: { custom: { contextWindow: 100, charsPerToken: 2 } } }).resolve('custom')
const blocks: ContentBlock[] = [
{ type: 'text', text: 'abcd' },
{ type: 'reasoning', text: 'ab' },
{ type: 'tool-call', id: CallId('c'), name: 'read', arguments: '{"x":1}' },
{
type: 'tool-result',
toolCallId: CallId('c'),
content: [{ type: 'text', text: 'xy' }],
isError: false,
},
{ type: 'future-block', payload: 'abcd' } as unknown as ContentBlock,
]
const estimated = handle.estimateMessage({ role: 'assistant', content: blocks })
expect(estimated).toBeGreaterThan(30)
expect(handle.estimateMessage(textMessage('abcd'))).toBe(10)
})
it('returns a detached deeply immutable empty measurement', () => {
const handle = meter().resolve('deepseek-v4-flash')
const session = new Session(SessionId('empty'))
const result = handle.measure(session)
expect(result).toEqual({
model: 'deepseek-v4-flash',
logRevision: 0,
baseline: { kind: 'none', tokens: 0 },
surfaceDeltaTokens: 0,
totalTokens: 0,
})
expect(Object.isFrozen(result)).toBe(true)
expect(Object.isFrozen(result.baseline)).toBe(true)
expect(() => {
;(result as { totalTokens: number }).totalTokens = 1
}).toThrow(TypeError)
})
it('keeps earlier scalar and surface snapshots detached from later replay', () => {
const handle = meter().resolve('deepseek-v4-flash')
const session = new Session(SessionId('detached'))
session.append('user/message', {
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const scalar = handle.measure(session)
const surface = handle.measureSurface(session)
const scalarCopy = structuredClone(scalar)
const surfaceCopy = structuredClone(surface)
session.append('user/message', {
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(handle.measure(session).logRevision).toBe(2)
expect(handle.measureSurface(session).nodes).toHaveLength(2)
expect(scalar).toEqual(scalarCopy)
expect(surface).toEqual(surfaceCopy)
expect(scalar.logRevision).toBe(1)
expect(surface.nodes).toHaveLength(1)
})
it('prices header, prefix, tools, and surface when no reusable usage exists', () => {
const handle = meter().resolve('deepseek-v4-flash')
const session = new Session(SessionId('heuristic'))
session.append('user/message', {
content: [{ type: 'text', text: 'question' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
appendHeader(session, header('deepseek-v4-flash', {
system: 'system',
messagePrefix: [textMessage('prefix')],
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
}))
const result = handle.measure(session)
expect(result.baseline.kind).toBe('estimated')
expect(result.totalTokens).toBeGreaterThan(handle.measureSurface(session).totalTokens)
expect(result.logRevision).toBe(session.events.length)
})
})
describe('replay anchors and surface folds', () => {
const USAGE: TokenUsage = {
inputTokens: 20,
cacheReadTokens: 3,
cacheWriteTokens: 4,
outputTokens: 7,
reasoningTokens: 6,
}
it('uses disjoint provider usage and signed durable-output rewrites', () => {
const handle = meter().resolve('deepseek-v4-flash')
const session = new Session(SessionId('usage'))
session.append('user/message', {
content: [{ type: 'text', text: 'before' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
appendSuccessfulCall(session, header('deepseek-v4-flash'), {
providerText: 'short',
durableText: 'a much longer rewritten durable assistant answer',
usage: USAGE,
})
const result = handle.measure(session)
expect(result.baseline).toMatchObject({ kind: 'usage', tokens: 34, usage: USAGE })
expect(result.surfaceDeltaTokens).toBeGreaterThan(0)
expect(result.totalTokens).toBe(34 + result.surfaceDeltaTokens)
expect(() => {
;((result.baseline as { usage: { inputTokens: number } }).usage.inputTokens) = 1
}).toThrow(TypeError)
})
it('uses an estimated anchor when provider usage is absent', () => {
const handle = meter().resolve('deepseek-v4-flash')
const session = new Session(SessionId('missing-usage'))
appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), {
providerText: 'provider',
durableText: 'rewritten',
})
const anchored = handle.measure(session)
expect(anchored.baseline.kind).toBe('estimated')
expect(anchored.surfaceDeltaTokens).toBe(0)
session.append('user/message', {
content: [{ type: 'text', text: 'later' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const advanced = handle.measure(session)
expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0)
})
it('distinguishes explicit empty provenance from absent legacy provenance', () => {
const explicit = new Session(SessionId('explicit-empty'))
const legacy = new Session(SessionId('legacy-absent'))
appendSuccessfulCall(explicit, header('deepseek-v4-flash'), {
durableText: 'listener injected text',
providerText: '',
usage: USAGE,
provenance: 'empty',
})
appendSuccessfulCall(legacy, header('deepseek-v4-flash'), {
durableText: 'listener injected text',
providerText: '',
usage: USAGE,
provenance: 'absent',
})
const handle = meter().resolve('deepseek-v4-flash')
expect(handle.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0)
expect(handle.measure(legacy).surfaceDeltaTokens).toBe(0)
})
it('preserves one model anchor across another model success and reuses it after switching back', () => {
const service = meter({
models: {
alpha: { contextWindow: 1000 },
beta: { contextWindow: 1000, charsPerToken: 2 },
},
})
const alpha = service.resolve('alpha')
const beta = service.resolve('beta')
const session = new Session(SessionId('switch'))
const alphaHeader = header('alpha', { system: 'same envelope' })
appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' })
expect(alpha.measure(session).baseline.kind).toBe('usage')
appendSuccessfulCall(session, header('beta'), {
turn: 1,
step: 2,
usage: { inputTokens: 100, outputTokens: 50 },
providerText: 'beta response',
})
expect(alpha.measure(session).baseline.kind).toBe('estimated')
expect(beta.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 150 })
appendHeader(session, alphaHeader)
const switchedBack = alpha.measure(session)
expect(switchedBack.baseline).toMatchObject({ kind: 'usage', tokens: 34 })
expect(switchedBack.surfaceDeltaTokens).toBeGreaterThan(0)
})
it('invalidates usage for any canonical envelope change or explicit override', () => {
const handle = meter().resolve('deepseek-v4-flash')
const session = new Session(SessionId('envelope'))
const anchoredHeader = header('deepseek-v4-flash', { system: 'one' })
appendSuccessfulCall(session, anchoredHeader, { usage: USAGE })
expect(handle.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage')
expect(handle.measure(session, header('deepseek-v4-flash', { system: 'two' })).baseline.kind)
.toBe('estimated')
expect(handle.measure(session, header('deepseek-v4-pro', { system: 'one' })).baseline.kind)
.toBe('estimated')
expect(handle.measure(session, {
...anchoredHeader,
config: { ...anchoredHeader.config, temperature: 0.2 },
}).baseline.kind).toBe('estimated')
expect(handle.measure(session, {
...anchoredHeader,
messagePrefix: [textMessage('prefix')],
}).baseline.kind).toBe('estimated')
expect(handle.measure(session, {
...anchoredHeader,
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
}).baseline.kind).toBe('estimated')
})
it('folds valid header deltas into the effective envelope', () => {
const session = new Session(SessionId('header-delta'))
appendHeader(session, header('deepseek-v4-flash'))
session.append('request/header-delta', { config: { model: 'deepseek-v4-pro' } })
const result = meter().resolve('deepseek-v4-flash').measure(session)
expect(result.baseline.kind).toBe('estimated')
expect(result.logRevision).toBe(2)
})
it('replays seeded append and replace operations with signed deltas', () => {
const service = meter()
const original = new Session(SessionId('surface-original'))
appendSuccessfulCall(original, header('deepseek-v4-flash'), {
usage: USAGE,
providerText: 'long provider answer '.repeat(100),
})
original.append('user/message', {
content: [{ type: 'text', text: 'new tail' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const seeded = new Session(SessionId('surface-seeded'), original.events)
const handle = service.resolve('deepseek-v4-flash')
const before = handle.measureSurface(seeded)
const beforeScalar = handle.measure(seeded)
expect(before.nodes).toHaveLength(2)
expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0)
const first = seeded.surface.nodes[0]!.seq
seeded.append('user/message', {
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] })
const after = handle.measureSurface(seeded)
const afterScalar = handle.measure(seeded)
expect(after.nodes).toHaveLength(2)
expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1)
expect(after.logRevision).toBe(seeded.events.length)
expect(Object.isFrozen(after.nodes)).toBe(true)
expect(Object.isFrozen(after.nodes[0])).toBe(true)
expect(afterScalar.surfaceDeltaTokens).toBeLessThan(0)
expect(before.nodes).toHaveLength(2)
expect(before.logRevision).toBe(original.events.length)
expect(beforeScalar.surfaceDeltaTokens).toBeGreaterThan(0)
})
it('prices an empty assistant surface anchor as zero', () => {
const session = new Session(SessionId('empty-assistant'))
appendSuccessfulCall(session, header('deepseek-v4-flash'), {
providerText: '',
durableText: '',
provenance: 'empty',
})
const surface = meter().resolve('deepseek-v4-flash').measureSurface(session)
const assistant = session.events.find(event => event.type === 'assistant/message')!
expect(surface.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }])
expect(surface.totalTokens).toBe(0)
})
})
describe('malformed replay and listener lifecycle', () => {
function expectRepeatedFailure(handle: ModelTokenMeter, session: Session, pattern: RegExp): void {
expect(() => handle.measure(session)).toThrow(pattern)
expect(() => handle.measure(session)).toThrow(pattern)
}
it('rejects a header delta before any snapshot transactionally', () => {
const session = new Session(SessionId('bad-delta'))
session.append('request/header-delta', { config: { model: 'deepseek-v4-flash' } })
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no preceding header/)
})
it('rejects a matching-model assistant without its step boundary transactionally', () => {
const session = new Session(SessionId('bad-step'))
appendHeader(session, header('deepseek-v4-flash'))
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'bad' }],
}, { surfaceOp: 'append', sourceEventSeqs: [] })
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /no matching step\/start/)
})
it('clears completed step boundaries and rejects overlapping or late step events', () => {
const overlapping = new Session(SessionId('overlapping-step'))
overlapping.append('step/start', { turn: 1, step: 1 })
overlapping.append('step/start', { turn: 1, step: 2 })
expectRepeatedFailure(
meter().resolve('deepseek-v4-flash'),
overlapping,
/arrived before turn 1\/step 1 ended/,
)
const late = new Session(SessionId('late-assistant'))
late.append('step/start', { turn: 1, step: 1 })
appendHeader(late, header('deepseek-v4-flash'))
late.append('step/end', { turn: 1, step: 1 })
late.append('assistant/message', {
turn: 1,
step: 1,
content: [],
}, { surfaceOp: 'append', sourceEventSeqs: [] })
expectRepeatedFailure(
meter().resolve('deepseek-v4-flash'),
late,
/no matching step\/start/,
)
const mismatchedEnd = new Session(SessionId('mismatched-end'))
mismatchedEnd.append('step/start', { turn: 1, step: 1 })
mismatchedEnd.append('step/end', { turn: 1, step: 2 })
expectRepeatedFailure(
meter().resolve('deepseek-v4-flash'),
mismatchedEnd,
/step\/end .* no matching step\/start/,
)
})
it('rejects invalid assistant provenance', () => {
const cases: Array<{
name: string
appendSource(session: Session): number[]
pattern: RegExp
}> = [
{
name: 'non-chunk',
appendSource(session) {
return [session.append('user/message', {
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' }).seq]
},
pattern: /is not assistant\/chunk/,
},
{
name: 'wrong-step',
appendSource(session) {
return [session.append('assistant/chunk', {
turn: 1,
step: 2,
chunk: { type: 'finish', reason: { kind: 'stop' } },
}).seq]
},
pattern: /belongs to another step/,
},
]
for (const testCase of cases) {
const session = new Session(SessionId(`bad-source-${testCase.name}`))
session.append('step/start', { turn: 1, step: 1 })
appendHeader(session, header('deepseek-v4-flash'))
const sourceEventSeqs = testCase.appendSource(session)
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'bad' }],
usage: { inputTokens: 1, outputTokens: 1 },
}, { surfaceOp: 'append', sourceEventSeqs })
expect(() => meter().resolve('deepseek-v4-flash').measure(session)).toThrow(testCase.pattern)
}
})
it('rejects repeated and non-earlier assistant provenance', () => {
const duplicate = new Session(SessionId('duplicate-source'))
duplicate.append('step/start', { turn: 1, step: 1 })
appendHeader(duplicate, header('deepseek-v4-flash'))
const source = duplicate.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'finish', reason: { kind: 'stop' } },
}).seq
duplicate.append('assistant/message', {
turn: 1,
step: 1,
content: [],
usage: { inputTokens: 1, outputTokens: 0 },
}, { surfaceOp: 'append', sourceEventSeqs: [source, source] })
expect(() => meter().resolve('deepseek-v4-flash').measure(duplicate)).toThrow(/repeats source seq/)
const future = new Session(SessionId('future-source'))
future.append('step/start', { turn: 1, step: 1 })
appendHeader(future, header('deepseek-v4-flash'))
future.append('assistant/message', {
turn: 1,
step: 1,
content: [],
usage: { inputTokens: 1, outputTokens: 0 },
}, { surfaceOp: 'append', sourceEventSeqs: [99] })
expect(() => meter().resolve('deepseek-v4-flash').measure(future)).toThrow(/is not earlier/)
})
it('does not partially apply a malformed assistant replacement', () => {
const session = new Session(SessionId('transactional-replace'))
session.append('user/message', {
content: [{ type: 'text', text: 'head' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
appendHeader(session, header('deepseek-v4-flash'))
const head = session.events[0]!.seq
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'replacement' }],
}, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] })
expectRepeatedFailure(
meter().resolve('deepseek-v4-flash'),
session,
/no matching step\/start/,
)
})
it('rejects corrupt replacement ranges without advancing the replay cursor', () => {
const session = new Session(SessionId('bad-replace'))
session.append('user/message', {
content: [{ type: 'text', text: 'head' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'text', text: 'bad' }],
source: { kind: 'user' },
}, { surfaceOp: { op: 'replace', start: 99, end: 99 }, sourceEventSeqs: [0] })
expectRepeatedFailure(meter().resolve('deepseek-v4-flash'), session, /invalid current range/)
})
it('handles earlier-reader catch-up, eager observation, and service reload', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let handle: ModelTokenMeter | undefined
const revisions: number[] = []
ctx.on('session/event', (session) => {
if (handle !== undefined) revisions.push(handle.measure(session).logRevision)
})
const firstFiber = await ctx.plugin(TokenMeterService)
handle = ctx.tokenMeter.resolve('deepseek-v4-flash')
const session = ctx.sessions.create(SessionId('listener-order'))
handle.measure(session)
session.append('user/message', {
content: [{ type: 'text', text: 'one' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(revisions).toEqual([1])
expect(handle.measure(session).logRevision).toBe(1)
await firstFiber.dispose()
const secondFiber = await ctx.plugin(TokenMeterService)
handle = ctx.tokenMeter.resolve('deepseek-v4-flash')
expect(handle.measure(session).logRevision).toBe(1)
await secondFiber.dispose()
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -32,6 +32,7 @@ Session log (per session):
- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal).
- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance.
Agent status (per agent):

View File

@@ -118,8 +118,8 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
}
}
if (se.sourceEventSeqs !== undefined) {
if (se.sourceEventSeqs.length === 0) {
throw new InvariantError('sourceEventSeqs must not be empty when present')
if (se.sourceEventSeqs.length === 0 && event.type !== 'assistant/message') {
throw new InvariantError('sourceEventSeqs must not be empty except on assistant/message')
}
const unique = new Set(se.sourceEventSeqs)
if (unique.size !== se.sourceEventSeqs.length) {

View File

@@ -487,13 +487,17 @@ describe('surface invariants', () => {
// no throw — well-formed replace op
})
it('rejects empty sourceEventSeqs', async () => {
it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
expect(() => {
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] })
}).toThrow(InvariantError)
}).not.toThrow()
expect(() => {
session.append('user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append', sourceEventSeqs: [] })
}).toThrow(/must not be empty except on assistant\/message/)
})
it('rejects duplicate sourceEventSeqs', async () => {