Merge remote-tracking branch 'origin/master' into claude/web-llm-pi-ai-config-385e24

# Conflicts:
#	docs/event-producer-consumer.md
This commit is contained in:
Yichen Jiang
2026-08-06 13:03:40 +08:00
409 changed files with 10797 additions and 2409 deletions

View File

@@ -29,17 +29,99 @@ export interface ToolMessageSource {
callId: CallId
}
/**
* What SHAPE of information a producer-supplied context carries, declared by
* the producer beside its provenance.
*
* `MessageSource.kind` answers *who produced this*; `form` answers *what kind
* of thing it is*, and the two axes are deliberately independent — several
* producers share one form (three snapshot producers today), and one producer
* may emit more than one form over a session.
*
* The vocabulary is SEMANTIC, never visual: a value states that the content is
* a file's instructions or a catalog of available items, and a consumer decides
* what that looks like. Colors, icons, ordering, and collapse defaults are the
* consumer's business and must not enter this union. It grows one value at a
* time as producers gain the structured fields their form needs; an absent or
* unknown value is the documented default, presented as opaque content.
*/
export type ContextForm =
/** Instructions read out of workspace files the model is expected to follow. */
| 'instructions'
/** A catalog of items available in this session, republished as it changes. */
| 'catalog'
/** Current state, where a later snapshot from the same producer supersedes an earlier one. */
| 'snapshot'
/** A one-off account of something that just happened; it supersedes nothing. */
| 'notice'
/** A message another agent addressed to this one. */
| 'relay'
/** Material lifted out of another session's log, possibly reduced on the way in. */
| 'recall'
/** One named contribution to a `snapshot`-form context, in assembly order. */
export interface ContextSnapshotSection {
/** The contributing subsystem's name. */
readonly name: string
/** That contribution's model-facing text, exactly as assembled. */
readonly text: string
}
/**
* Producer-declared {@link ContextForm} and the fields that form requires,
* mixed into the source shapes that carry one.
*
* Discriminated by `form` so a producer cannot declare a shape without the
* facts that shape is presented from: a `notice` must record its one-line
* account, a `snapshot` its sections. Omitting `form` stays valid — an
* undeclared context is the documented default.
*/
export type ContextFormed =
| { readonly form?: never }
| { readonly form: 'instructions' }
| { readonly form: 'catalog' }
| {
readonly form: 'snapshot'
/** The named contributions this snapshot assembled, in order. */
readonly sections: readonly ContextSnapshotSection[]
}
| {
readonly form: 'notice'
/** One-line account of what happened, shown without expanding the row. */
readonly summary: string
}
| { readonly form: 'relay' }
| { readonly form: 'recall' }
/**
* Where a message (or injected content) came from.
* Merge-extensible sum type — plugins add their own `kind`s.
*/
export interface MessageSourceMap {
user: { kind: 'user' }
plugin: { kind: 'plugin'; plugin: string }
plugin: { kind: 'plugin'; plugin: string } & ContextFormed
model: ModelMessageSource
tool: ToolMessageSource
}
/**
* Bound for a `notice` summary. The account rides a collapsed transcript row
* and is committed to the durable log, while its inputs — task labels, goal
* objectives, tool arguments — are caller text with no length of their own.
*/
export const CONTEXT_SUMMARY_MAX_CHARS = 120
/**
* Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
* @param summary - the producer's one-line account, of any length.
* @returns the account, ellipsized when it exceeds the bound.
*/
export function boundContextSummary(summary: string): string {
return summary.length <= CONTEXT_SUMMARY_MAX_CHARS
? summary
: `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}`
}
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
README.md: 0935a48a5f5773fbb280bc45e07faaa05c0a4f6e
README.zh.md: 83282ab47e6d406cfca30bbd8af40e0c94050504
README.md: 8f868f25f3c4caf1fdab5b50965aab41efecf5af
README.zh.md: 3621105ff35606b62b0587063038116b4772c6cf

View File

@@ -23,17 +23,21 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket
## Session projections
When the composition provides `ctx.sessionProjections`, token-meter registers two units through an optional child fiber.
When the composition provides `ctx.sessionProjections`, token-meter registers three units through an optional child fiber.
`tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again.
`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and optional `contextWindow` from the newest `request/context` record. Pressure stays absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage.
`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — optional `projectedTokens`, and optional `contextWindow` from the newest `request/context` record. Both figures stay absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so `pressureTokens` holds still while a turn streams and steps forward when the next request reports its usage.
Both units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes both keys. A composition without the projection seam keeps the measurement service's existing behavior.
`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero and folded through the same `surface-fold.ts` the measurement service replays. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`.
`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays `surface-fold.ts` — the same positional fold `measure()` runs — so it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total.
All three units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes all three keys. A composition without the projection seam keeps the measurement service's existing behavior.
### Context occupancy is an approximation, by design
`pressureTokens` and `contextWindow` are independent last-wins fields and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's pressure until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now.
The occupancy fields are independent last-wins records and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's sample until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now`projectedTokens` carries that sample forward over the surface's movement, but its anchor is still the older request.
This is deliberate. An occupancy percentage is a user-facing reference figure, not a billing record or a gating input — nothing in the harness makes decisions from it, and compaction reads `measure()` instead. A UI computes occupancy by dividing measured pressure by the separately resolved capacity for the selected model.

View File

@@ -23,17 +23,21 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
## 会话投影
当组合提供 `ctx.sessionProjections`token-meter 会通过一个可选子 fiber 注册个单元。
当组合提供 `ctx.sessionProjections`token-meter 会通过一个可选子 fiber 注册个单元。
`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens``outputTokens``cacheReadTokens``cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前压力保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和)、可选的 `projectedTokens`,以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前两个数字都保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间 `pressureTokens` 保持不动,等到下一个请求报告用量时才前进。
两个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这两个键。不带投影 seam 的组合会保留测量服务的既有行为
`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到又跑完一整轮为止。占用率展示读取 `projectedTokens`
`contextBreakdown` 携带启发式的 `systemTokens``toolsTokens``messageTokens`描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——与 `measure()` 运行的位置折叠是同一份——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点恰好把这些明细行仍然带着的误差排除在外按「4 字符 ≈ 1 token」计价CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。
三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。
### 上下文占用率是刻意为之的近似值
`pressureTokens``contextWindow` 是两个各自后者胜的独立字段**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的压力配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层。
这些占用率字段各自后者胜、彼此独立**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的样本配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层——`projectedTokens` 把该样本沿表层的增减推进到当下,但它的锚点仍然是那个较早的请求
这是刻意的选择。占用率百分比是面向用户的参考数字既不是计费记录也不是门控输入harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。UI 用测得的压力除以为所选模型单独解析出的容量来计算占用率。

View File

@@ -30,6 +30,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -41,6 +42,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -0,0 +1,69 @@
/**
* Pure fold for the heuristic context-composition projection: system prompt
* and tool schemas from the newest request envelope, conversation from the
* live surface. Prices with the same shared estimator as the meter service,
* so the three figures match `measure()`'s heuristic vocabulary exactly.
*/
import { z } from 'zod'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts'
import { foldSurfaceProjection } from './surface-projection.ts'
import type { ShadowPriceClaim } from './surface-projection.ts'
// Import for the `contextBreakdown` SessionProjectionMap key merge.
import type {} from './projection.ts'
interface ContextBreakdownState {
systemTokens: number
toolsTokens: number
messageTokens: number
/** Shadow price armed by the immediately preceding metering event. */
claim?: ShadowPriceClaim
}
const breakdownSchema = z.object({
systemTokens: z.number().int().nonnegative(),
toolsTokens: z.number().int().nonnegative(),
messageTokens: z.number().int().nonnegative(),
}).strict()
/**
* Token-meter's context-composition projection unit.
*
* Envelope figures are last-wins per `request/header`; the message figure
* rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy
* projection uses — so it equals `measure().surfaceTokens` at every event
* boundary and compaction shrinks it by its logged shadow price, the way it
* shrinks the next request. The state is a fixed handful of numbers, so the
* persisted checkpoint stays O(1) over the session's life.
*/
export const contextBreakdownProjectionDefinition:
ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = {
key: 'contextBreakdown',
schema: breakdownSchema,
init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }),
apply: (state, event) => {
const fold = foldSurfaceProjection(state.claim, event)
let systemTokens = state.systemTokens
let toolsTokens = state.toolsTokens
if (event.type === 'request/header') {
const header = canonicalHeader(event.data.header)
systemTokens = estimateSystemTokens(header)
toolsTokens = estimateToolsTokens(header)
}
if (systemTokens === state.systemTokens
&& toolsTokens === state.toolsTokens
&& fold.deltaTokens === 0
&& fold.claim === undefined
&& state.claim === undefined) return state
return {
systemTokens,
toolsTokens,
messageTokens: state.messageTokens + fold.deltaTokens,
...fold.claim === undefined ? {} : { claim: fold.claim },
}
},
view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }),
stateVersion: 2,
}

View File

@@ -0,0 +1,87 @@
/**
* Fixed-density heuristic token pricing shared by the meter service and the
* pure context-breakdown projection, so both surfaces price identical content
* to identical numbers.
*
* @module @deepseek-ai/dsh-token-meter/estimate
*/
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import type { EpochHeader } from '@deepseek-ai/dsh-session'
/** Fixed text-density estimate used until exact tokenization is needed. */
const CHARS_PER_TOKEN = 4
/** Per-block structural overhead for JSON framing and type tags. */
const BLOCK_OVERHEAD = 4
/** Role-field framing overhead added to every priced message. */
export const ROLE_OVERHEAD = 4
/**
* Price content blocks recursively under the fixed density heuristic.
* @param blocks - content blocks to price without mutation.
* @returns heuristic tokens including per-block structural overhead.
*/
export function estimateContent(blocks: readonly ContentBlock[]): number {
let tokens = 0
for (const block of blocks) {
switch (block.type) {
case 'text':
case 'reasoning':
tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
break
case 'tool-call':
tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN)
+ Math.ceil(block.arguments.length / CHARS_PER_TOKEN)
+ BLOCK_OVERHEAD
break
case 'tool-result':
tokens += estimateContent(block.content) + BLOCK_OVERHEAD
break
default:
// ContentBlockMap is merge-extensible; unknown blocks retain a
// conservative structural JSON price under the fixed heuristic.
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
}
}
return tokens
}
/**
* Heuristically price one model-visible message.
* @param message - message to price without mutation.
* @returns content and role-framing tokens under the fixed heuristic.
*/
export function estimateMessage(message: Message): number {
return estimateContent(message.content) + ROLE_OVERHEAD
}
/**
* Price the system-prompt part of a canonical request envelope.
* @param header - canonical envelope, or undefined before any request.
* @returns heuristic system-prompt tokens; 0 when absent.
*/
export function estimateSystemTokens(header: EpochHeader | undefined): number {
if (header?.system === undefined) return 0
return Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
}
/**
* Price the tool-schema part of a canonical request envelope.
* @param header - canonical envelope, or undefined before any request.
* @returns heuristic tool-schema tokens; 0 when absent or empty.
*/
export function estimateToolsTokens(header: EpochHeader | undefined): number {
if (header?.tools === undefined || header.tools.length === 0) return 0
return Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
}
/**
* Price the complete non-surface request envelope.
* @param header - canonical envelope, or undefined before any request.
* @returns heuristic system plus tool tokens.
*/
export function estimateHeader(header: EpochHeader | undefined): number {
return estimateSystemTokens(header) + estimateToolsTokens(header)
}

View File

@@ -7,8 +7,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
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 type { Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
// Type-only: resolves the optional projection registry Context seam.
import type {} from '@deepseek-ai/dsh-session-projection'
@@ -18,19 +18,13 @@ import type {
TokenMeterConfig,
TokenSurfaceNode,
} from './types.ts'
import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts'
import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts'
import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from './estimate.ts'
import { foldSurfaceTokens } from './surface-fold.ts'
export type * from './types.ts'
/** Fixed text-density estimate used until exact tokenization is needed. */
const CHARS_PER_TOKEN = 4
/** 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 MeasurementAnchor {
readonly header: EpochHeader | undefined
readonly surfaceTokens: number
@@ -46,11 +40,6 @@ interface ReplayState {
anchor: MeasurementAnchor | undefined
}
interface PreparedSurfaceMutation {
readonly tokens: number
commit(state: ReplayState): void
}
/** Sum disjoint provider usage buckets without double-counting reasoning output. */
function usageTokens(usage: TokenUsage): number {
return usage.inputTokens
@@ -98,6 +87,7 @@ export class TokenMeterService extends Service {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition)
projectionCtx.sessionProjections.register(contextPressureProjectionDefinition)
projectionCtx.sessionProjections.register(contextBreakdownProjectionDefinition)
})
// Readers catch up independently, while eager observation bounds ordinary
@@ -141,7 +131,7 @@ export class TokenMeterService extends Service {
} else {
baseline = {
kind: 'estimated',
tokens: this._estimateHeader(header) + state.surfaceTokens,
tokens: estimateHeader(header) + state.surfaceTokens,
}
surfaceDeltaTokens = 0
}
@@ -157,12 +147,13 @@ export class TokenMeterService extends Service {
}
/**
* Heuristically price one model-visible message.
* Heuristically price one model-visible message (instance face of the pure
* `estimateMessage` export from `estimate.ts`).
* @param message - message to price without mutation.
* @returns content and role-framing tokens under the fixed service heuristic.
*/
estimateMessage(message: Message): number {
return this._estimateContent(message.content) + ROLE_OVERHEAD
return estimateMessage(message)
}
/** Catch one session's fold up to the current durable tail. */
@@ -224,7 +215,7 @@ export class TokenMeterService extends Service {
}
const surface = isSurfaceEvent(event)
? this._prepareSurfaceMutation(session, state, event)
? foldSurfaceTokens(state.surface, event)
: undefined
if (event.type === 'assistant/message') {
@@ -246,7 +237,7 @@ export class TokenMeterService extends Service {
)
const anchorSurfaceTokens = stepStart.surfaceTokens + providerAssistantTokens
const providerTokens = usageTokens(event.data.usage)
const estimatedAnchorTokens = this._estimateHeader(nextHeader) + anchorSurfaceTokens
const estimatedAnchorTokens = estimateHeader(nextHeader) + anchorSurfaceTokens
nextAnchor = {
header: nextHeader,
surfaceTokens: anchorSurfaceTokens,
@@ -263,7 +254,7 @@ export class TokenMeterService extends Service {
surfaceTokens: anchorSurfaceTokens,
baseline: {
kind: 'estimated',
tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens,
tokens: estimateHeader(nextHeader) + anchorSurfaceTokens,
},
}
}
@@ -271,53 +262,13 @@ export class TokenMeterService extends Service {
state.header = nextHeader
state.stepStart = nextStepStart
if (surface !== undefined) surface.commit(state)
if (surface !== undefined) {
state.surface = surface.nodes
state.surfaceTokens += surface.deltaTokens
}
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
@@ -355,46 +306,7 @@ export class TokenMeterService extends Service {
assembler.push(sourceEvent.data.chunk)
}
const providerContent = assembler.blocks()
return providerContent.length === 0 ? 0 : this._estimateContent(providerContent) + ROLE_OVERHEAD
}
/** Price content blocks recursively under the fixed density heuristic. */
private _estimateContent(blocks: readonly ContentBlock[]): number {
let tokens = 0
for (const block of blocks) {
switch (block.type) {
case 'text':
case 'reasoning':
tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
break
case 'tool-call':
tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN)
+ Math.ceil(block.arguments.length / CHARS_PER_TOKEN)
+ BLOCK_OVERHEAD
break
case 'tool-result':
tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD
break
default:
// ContentBlockMap is merge-extensible; unknown blocks retain a
// conservative structural JSON price under the fixed heuristic.
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
}
}
return tokens
}
/** Price the canonical non-surface request envelope. */
private _estimateHeader(header: EpochHeader | undefined): number {
if (header === undefined) return 0
let tokens = 0
if (header.system !== undefined) {
tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
}
if (header.tools !== undefined && header.tools.length > 0) {
tokens += Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
}
return tokens
return providerContent.length === 0 ? 0 : estimateContent(providerContent) + ROLE_OVERHEAD
}
}

View File

@@ -17,9 +17,14 @@ export const inject = ['invariants']
/**
* No runtime invariant: token estimates are per-call outputs and the private
* session cache is invalidated at its event mutation boundary. The package's
* projection does expose an observation stream, but its schema fixes the JSON
* payload and its pure fold replaces same-step samples; totals need not be
* monotone when a final usage sample corrects an earlier chunk.
* three projections do expose observation streams, but their schemas fix the
* JSON payloads; the usage folds replace same-step samples, so totals need not
* be monotone when a final sample corrects an earlier chunk, and the
* composition fold prices through the same `estimate.ts` heuristic as the
* measurement service and subtracts producer-logged shadow prices derived
* from that service's own nodes, which makes its message figure equal
* `measure().surfaceTokens` by construction rather than by a relation worth
* observing at runtime.
*/
const install: InvariantInstaller = () => {}

View File

@@ -20,13 +20,12 @@ export interface TokenUsageProjection {
/**
* Approximate context occupancy for a status display.
*
* The two fields, when present, are deliberately NOT one atomic request
* observation: `pressureTokens` is the newest provider-reported prompt size,
* `contextWindow` the newest recorded route capacity. Switching models can
* therefore pair a fresh capacity with the previous route's pressure until the
* next request reports usage. This is an intentional trade — the value is a
* user-facing reference, not a billing or gating input. See the token-meter
* README for the full rationale.
* The fields, when present, are deliberately NOT one atomic request
* observation: each is a last-wins record of a different moment. Switching
* models can therefore pair a fresh capacity with the previous route's
* pressure until the next request reports usage. This is an intentional trade
* — the value is a user-facing reference, not a billing or gating input. See
* the token-meter README for the full rationale.
*/
export interface ContextPressureProjection {
/**
@@ -35,15 +34,44 @@ export interface ContextPressureProjection {
* grow as the current turn streams. Absent until a provider reports usage.
*/
pressureTokens?: number
/**
* What the NEXT request's prompt would cost: {@link pressureTokens} plus the
* heuristic repricing of everything the surface gained or lost since that
* sample. Only the delta is estimated, so the figure stays anchored to the
* provider while still reacting the moment a compaction shadows a span —
* which `pressureTokens` alone cannot do, since compaction reports no usage
* of its own. Absent until a provider reports usage.
*/
projectedTokens?: number
/** Newest recorded route capacity; absent when no adapter advertised one. */
contextWindow?: number
}
/**
* Heuristic composition of the next request's context: what the prompt is
* made of, not what it costs. All three figures use the meter's fixed
* density estimate, so they will not sum to the provider-anchored
* `projectedTokens`: the estimator systematically underprices CJK text and
* JSON schemas, which is exactly the error the anchoring in
* {@link ContextPressureProjection.projectedTokens} keeps out of the occupancy
* figure. Present these as approximations of composition, never as a total.
*/
export interface ContextBreakdownProjection {
/** Heuristic tokens of the newest request envelope's system prompt; 0 before any request. */
systemTokens: number
/** Heuristic tokens of the newest request envelope's tool schemas; 0 before any request. */
toolsTokens: number
/** Heuristic tokens of the current model-visible conversation surface. */
messageTokens: number
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Provider-reported usage accumulated across the complete durable log. */
tokenUsage: TokenUsageProjection
/** Newest request pressure paired with the newest known route capacity. */
contextPressure: ContextPressureProjection
/** Heuristic system/tools/message composition of the next request. */
contextBreakdown: ContextBreakdownProjection
}
}

View File

@@ -0,0 +1,64 @@
/**
* The measurement service's positional surface fold: the per-node priced
* surface `measure()` serves and compaction plans against. The projection
* units deliberately do NOT share this fold — their state must stay O(1)
* for the persisted checkpoint, so they ride `surface-projection.ts`'s
* shadow-price protocol instead. The two stay in agreement by construction:
* both price through `estimate.ts`, and every logged shadow price is derived
* from THIS fold's nodes by the replace producer.
*
* @module @deepseek-ai/dsh-token-meter/surface-fold
*/
import { deriveEventMessage } from '@deepseek-ai/dsh-session'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
import type { TokenSurfaceNode } from './types.ts'
import { estimateMessage } from './estimate.ts'
/** One surface event's placement and cost against the surface preceding it. */
export interface SurfaceTokenFold {
/** Heuristic price of the event's own message; 0 when it derives none. */
readonly tokens: number
/** The surface after the event, detached from the input. */
readonly nodes: TokenSurfaceNode[]
/** Signed change in the surface total: `tokens` minus anything shadowed. */
readonly deltaTokens: number
}
/**
* Fold one surface event onto a priced surface.
*
* Total and allocation-fresh: the caller assigns the result rather than
* mutating in place, so a throw here leaves the caller's state untouched and
* the same malformed event fails identically on every retry.
* @param nodes - the priced surface preceding this event, in model-visible order.
* @param event - the surface event to place.
* @returns the event's price, the next surface, and the signed total delta.
* @throws when a replacement names a range absent from `nodes` — committed
* logs are surface-validated at append time, so an unresolvable range is log
* corruption and must fail loud rather than skip the event.
*/
export function foldSurfaceTokens(
nodes: readonly TokenSurfaceNode[],
event: SurfaceEvent,
): SurfaceTokenFold {
const message = deriveEventMessage(event)
const tokens = message === null ? 0 : estimateMessage(message)
const op = event.surfaceOp
if (op === 'append') {
return { tokens, nodes: [...nodes, { seq: event.seq, tokens }], deltaTokens: tokens }
}
const startIdx = nodes.findIndex(node => node.seq === op.start)
const endIdx = nodes.findIndex(node => node.seq === op.end)
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
throw new Error(
`token surface: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`,
)
}
const removed = nodes
.slice(startIdx, endIdx + 1)
.reduce((total, node) => total + node.tokens, 0)
const next = [...nodes]
next.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens })
return { tokens, nodes: next, deltaTokens: tokens - removed }
}

View File

@@ -0,0 +1,84 @@
/**
* The O(1) surface-token fold shared by the token-meter projection units.
*
* A projection state must stay bounded — the persisted projection cache
* checkpoints every unit's whole state, so carrying the priced surface
* (one node per model-visible message) would grow a checkpoint without
* bound over the session's life. Instead, replacements ride the compact
* seam's shadow-price protocol: the metering event immediately before a
* surface `replace` (`compact/summary` or `compact/prune`) states the
* heuristic price of the exact replaced range, so the fold keeps a running
* total plus at most one pending claim and never retains per-node prices.
* The counts are exact by construction: producers derive them from the same
* fixed estimator this module prices appends with.
*
* @module @deepseek-ai/dsh-token-meter/surface-projection
*/
import { deriveEventMessage, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
// Type-only: the `compact/*` SessionEventMap merges (shadow-price events).
import type {} from '@deepseek-ai/dsh-compact'
import { estimateMessage } from './estimate.ts'
/**
* One armed shadow price: the heuristic tokens of the surface range the
* IMMEDIATELY following event replaces. Plain JSON — it is part of the
* persisted unit state while armed.
*/
export interface ShadowPriceClaim {
/** Declared inclusive first surface-node seq of the priced range. */
start: number
/** Declared inclusive last surface-node seq of the priced range. */
end: number
/** Heuristic tokens of the priced range under the fixed estimator. */
tokens: number
}
/** One event's effect on a running surface-token total. */
export interface SurfaceTokensFold {
/** Signed change in the surface total; 0 for events off the surface. */
readonly deltaTokens: number
/** Claim to carry into the next event; undefined when none survives. */
readonly claim: ShadowPriceClaim | undefined
}
/**
* Fold one committed event onto a running surface-token total.
*
* A shadow-price event arms a claim; any other event expires it, and a
* surface `replace` must consume a claim naming its exact range — the
* producers append the metering event and the replacement synchronously
* adjacent, so a surviving claim always prices the very next event.
* @param claim - the claim armed by the immediately preceding event, if any.
* @param event - the next committed session event.
* @returns the signed token delta and the claim state after this event.
* @throws when a replacement arrives without a claim for its exact range —
* every in-repo replace producer meters its replacement, so an unpriced
* replacement is a shadow-price contract violation and must fail loud
* rather than let the total drift.
*/
export function foldSurfaceProjection(
claim: ShadowPriceClaim | undefined,
event: SessionEvent,
): SurfaceTokensFold {
if (event.type === 'compact/summary' || event.type === 'compact/prune') {
const { shadowedRange, shadowedTokenCount } = event.data
return {
deltaTokens: 0,
claim: { start: shadowedRange.start, end: shadowedRange.end, tokens: shadowedTokenCount },
}
}
if (!isSurfaceEvent(event)) return { deltaTokens: 0, claim: undefined }
const message = deriveEventMessage(event)
const tokens = message === null ? 0 : estimateMessage(message)
const op = event.surfaceOp
if (op === 'append') return { deltaTokens: tokens, claim: undefined }
if (claim === undefined || claim.start !== op.start || claim.end !== op.end) {
throw new Error(
`token surface: replace at seq ${event.seq} over range ${op.start}-${op.end} has no adjacent shadow price`
+ (claim === undefined ? '' : ` (armed claim covers ${claim.start}-${claim.end})`),
)
}
return { deltaTokens: tokens - claim.tokens, claim: undefined }
}

View File

@@ -6,7 +6,7 @@
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
export type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
export type { ContextBreakdownProjection, ContextPressureProjection, TokenUsageProjection } from './projection.ts'
/** Token-meter plugin configuration; the fixed estimator has no settings. */
export type TokenMeterConfig = Record<string, never>

View File

@@ -4,8 +4,11 @@
import { z } from 'zod'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
import { foldSurfaceProjection } from './surface-projection.ts'
import type { ShadowPriceClaim } from './surface-projection.ts'
interface UsageSample {
turn: number
@@ -60,6 +63,7 @@ const projectionSchema = z.object({
// `number | undefined` where the interface declares absent-or-number fields.
const pressureSchema = z.object({
pressureTokens: z.number().int().nonnegative().optional(),
projectedTokens: z.number().int().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}).strict() as unknown as z.ZodType<ContextPressureProjection>
@@ -67,6 +71,29 @@ const pressureSchema = z.object({
const pressureFrom = (usage: TokenUsage): number =>
usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
/** The usage a chunk or finalized message reports for its step, if any. */
const usageOf = (event: SessionEvent): TokenUsage | undefined =>
event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
? event.data.chunk.usage
: event.type === 'assistant/message'
? event.data.usage
: undefined
/**
* Context-occupancy state: the two independent last-wins records plus the
* O(1) running surface total needed to carry the newest sample forward.
*/
interface ContextPressureState {
contextWindow?: number
pressureTokens?: number
/** Running heuristic total over the current surface ({@link foldSurfaceProjection}). */
surfaceTokens: number
/** {@link surfaceTokens} at the newest usage sample; absent until one lands. */
sampledSurfaceTokens?: number
/** Shadow price armed by the immediately preceding metering event. */
claim?: ShadowPriceClaim
}
/**
* Token-meter's session projection unit.
*
@@ -115,39 +142,64 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = {
/**
* Token-meter's context-occupancy projection unit.
*
* Two independent last-wins slots: the newest usage sample supplies the
* Independent last-wins slots: the newest usage sample supplies the provider
* numerator, the newest `request/context` record the denominator. Both are
* whole values, so replay order alone decides the result and no cross-field
* consistency is claimed — the pair is explicitly not one atomic request
* observation (see {@link ContextPressureProjection}).
*
* The numerator is prompt-side only, so it holds still while a turn streams
* and steps forward once the next request reports its usage.
* `pressureTokens` is prompt-side only, so it holds still while a turn streams
* and steps forward once the next request reports its usage. Because nothing
* but a request reports usage, it also cannot see a compaction: the fold
* therefore carries a running surface total alongside it and publishes
* `projectedTokens` — the sample plus the surface's signed movement since it
* was taken — so occupancy answers for the next request rather than the last
* one. The total rides {@link foldSurfaceProjection}, so the state stays O(1)
* and a replacement shrinks it by its logged shadow price. A usage sample is
* stamped BEFORE the same event joins the surface, so an `assistant/message`
* anchors against the surface its own request saw.
*/
export const contextPressureProjectionDefinition:
ProjectionDefinition<'contextPressure', ContextPressureProjection> = {
ProjectionDefinition<'contextPressure', ContextPressureState> = {
key: 'contextPressure',
schema: pressureSchema,
init: () => ({}),
init: () => ({ surfaceTokens: 0 }),
apply: (state, event) => {
const fold = foldSurfaceProjection(state.claim, event)
let next = state
if (event.type === 'request/context') {
const contextWindow = event.data.contextWindow
if (contextWindow === state.contextWindow) return state
if (contextWindow !== undefined) return { ...state, contextWindow }
const { contextWindow: _removed, ...withoutContextWindow } = state
return withoutContextWindow
if (contextWindow !== state.contextWindow) {
if (contextWindow !== undefined) {
next = { ...next, contextWindow }
} else {
const { contextWindow: _removed, ...withoutContextWindow } = next
next = withoutContextWindow
}
}
}
const usage = event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
? event.data.chunk.usage
: event.type === 'assistant/message'
? event.data.usage
: undefined
if (usage === undefined) return state
const pressureTokens = pressureFrom(usage)
return pressureTokens === state.pressureTokens
? state
: { ...state, pressureTokens }
const usage = usageOf(event)
if (usage !== undefined) {
const pressureTokens = pressureFrom(usage)
if (pressureTokens !== next.pressureTokens || next.sampledSurfaceTokens !== next.surfaceTokens) {
next = { ...next, pressureTokens, sampledSurfaceTokens: next.surfaceTokens }
}
}
if (fold.deltaTokens !== 0) {
next = { ...next, surfaceTokens: next.surfaceTokens + fold.deltaTokens }
}
// A defined fold.claim is always freshly built, so presence decides claim
// bookkeeping: no claim before or after this event leaves `next` as is.
if (state.claim === undefined && fold.claim === undefined) return next
const { claim: _expired, ...withoutClaim } = next
return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim }
},
view: state => state,
stateVersion: 2,
view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({
...contextWindow === undefined ? {} : { contextWindow },
...pressureTokens === undefined ? {} : { pressureTokens },
...pressureTokens === undefined || sampledSurfaceTokens === undefined
? {}
: { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) },
}),
stateVersion: 4,
}

View File

@@ -0,0 +1,307 @@
// contextBreakdown projection: heuristic system/tools/message composition,
// plus the shared estimator's pricing branches.
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts'
import {
estimateContent,
estimateHeader,
estimateMessage,
estimateSystemTokens,
estimateToolsTokens,
} from '../src/estimate.ts'
const CONFIG = { provider: 'test', model: 'test-model' }
const TOOLS: ToolSchema[] = [{
name: 'bash',
description: 'run a command',
parameters: { type: 'object', properties: {} },
}]
async function harness(): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(TokenMeterService)
return { ctx, session: ctx.sessions.create() }
}
const projected = (ctx: Context, session: Session): ContextBreakdownProjection => {
const value = ctx.sessionProjections.snapshot(session).values.contextBreakdown
if (value === undefined) throw new Error('contextBreakdown projection is not registered')
return value
}
function appendUser(session: Session, text: string): number {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' }).seq
}
/**
* Meter one upcoming replacement the way compact-basic does: price the
* replaced span from the measurement service's own nodes and log the
* shadow-price event directly before the replace.
*/
function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void {
const nodes = ctx.tokenMeter.measure(session).nodes
const startIdx = nodes.findIndex(node => node.seq === start)
const endIdx = nodes.findIndex(node => node.seq === end)
const shadowed = nodes.slice(startIdx, endIdx + 1)
session.append('compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start, end },
shadowedSeqs: shadowed.map(node => node.seq),
shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0),
provider: 'mock',
model: 'mock',
})
}
describe('contextBreakdown session projection', () => {
it('serves zeros for an empty log', async () => {
const { ctx, session } = await harness()
expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 })
})
it('prices the newest envelope last-wins and pushes no change for a restated one', async () => {
const { ctx, session } = await harness()
session.append('request/header', {
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
reason: 'initial',
})
expect(projected(ctx, session)).toEqual({
systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }),
toolsTokens: estimateToolsTokens({ config: CONFIG, tools: TOOLS }),
messageTokens: 0,
})
const changed: string[] = []
ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
session.append('request/header', {
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
reason: 'change',
})
session.append('todo/write', { todos: [] })
expect(changed).not.toContain('contextBreakdown')
// A system-less, tool-less envelope prices back to zero.
session.append('request/header', { header: { config: CONFIG }, reason: 'change' })
expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 })
})
it('sums surface appends and skips an empty-content assistant message', async () => {
const { ctx, session } = await harness()
appendUser(session, 'abcd')
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage: { inputTokens: 9, outputTokens: 0 },
}, { surfaceOp: 'append', sourceEventSeqs: [] })
session.append('step/end', { turn: 1, step: 1 })
// 'abcd' prices to 9 (1 text + 4 block + 4 role); the usage-only assistant
// message derives to no transcript entry and adds nothing.
expect(projected(ctx, session).messageTokens).toBe(9)
})
it('shrinks the message figure when a metered replacement compacts the surface', async () => {
const { ctx, session } = await harness()
const first = appendUser(session, 'before compaction, a longer message')
const second = appendUser(session, 'and a second entry')
const summary = createUserMessage({
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'plugin', plugin: 'test' },
})
appendSummaryMeter(ctx, session, first, second)
session.append('user/message', summary, {
surfaceOp: { op: 'replace', start: first, end: second },
sourceEventSeqs: [first, second],
})
expect(projected(ctx, session).messageTokens).toBe(estimateMessage(summary))
})
it('keeps the message figure equal to the service surface across appends and a compaction', async () => {
const { ctx, session } = await harness()
// The panel's composition rows and `measure()` answer the same question in
// the same vocabulary; one shared fold is what makes that true.
const agree = (): number => {
const messageTokens = projected(ctx, session).messageTokens
expect(messageTokens).toBe(ctx.tokenMeter.measure(session).surfaceTokens)
return messageTokens
}
session.append('request/header', {
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
reason: 'initial',
})
expect(agree()).toBe(0)
const question = appendUser(session, 'a first question, long enough to price above zero')
session.append('step/start', { turn: 1, step: 1 })
const answer = session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'a considered answer' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage: { inputTokens: 40, outputTokens: 7 },
}, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
session.append('step/end', { turn: 1, step: 1 })
const grown = agree()
expect(grown).toBeGreaterThan(0)
appendSummaryMeter(ctx, session, question, answer)
// The armed shadow price must not move the published figure by itself.
expect(agree()).toBe(grown)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'plugin', plugin: 'test' },
}), {
surfaceOp: { op: 'replace', start: question, end: answer },
sourceEventSeqs: [question, answer],
})
expect(agree()).toBeLessThan(grown)
})
it('fails loud on a replacement without an adjacent matching shadow price', () => {
const definition = contextBreakdownProjectionDefinition
const replace = (start: number, end: number): SessionEvent => ({
type: 'user/message',
seq: 9,
time: 0,
data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [start, end],
} as unknown as SessionEvent)
const append = (seq: number): SessionEvent => ({
type: 'user/message',
seq,
time: 0,
data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
surfaceOp: 'append',
} as unknown as SessionEvent)
const meter = (start: number, end: number, seq: number): SessionEvent => ({
type: 'compact/prune',
seq,
time: 0,
data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 },
} as unknown as SessionEvent)
let state = definition.init()
state = definition.apply(state, append(1))
state = definition.apply(state, append(3))
// No metering event at all.
expect(() => definition.apply(state, replace(1, 3))).toThrow('no adjacent shadow price')
// A claim for a different range does not price this replacement.
const mismatched = definition.apply(state, meter(1, 1, 8))
expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
// A claim expires after one intervening event instead of lingering.
let expired = definition.apply(state, meter(1, 3, 8))
expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
expect(() => definition.apply(expired, replace(1, 3))).toThrow('no adjacent shadow price')
// The armed claim prices exactly the next event's matching replacement.
const armed = definition.apply(state, meter(1, 3, 8))
expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens)
.toBe(definition.view(state).messageTokens - 5 + estimateMessage(
createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
))
})
it('keeps the persisted checkpoint O(1) as the surface grows and compacts', async () => {
const { ctx, session } = await harness()
const first = appendUser(session, 'the first of many messages')
for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`)
const last = appendUser(session, 'the last message before compaction')
const stateKeys = (): string[] => {
const row = ctx.sessionProjections.checkpoint(session)['contextBreakdown']
if (row === undefined) throw new Error('contextBreakdown checkpoint row is missing')
return Object.keys(row.val as Record<string, unknown>).sort()
}
// Growth adds no per-node bookkeeping to the durable state.
expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens'])
const shadowed = session.surface.nodes.slice(
session.surface.nodes.indexOf(first),
session.surface.nodes.indexOf(last) + 1,
)
appendSummaryMeter(ctx, session, first, last)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'plugin', plugin: 'test' },
}), {
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [...shadowed],
})
expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens'])
expect(projected(ctx, session).messageTokens)
.toBe(ctx.tokenMeter.measure(session).surfaceTokens)
})
it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const meterFiber = await ctx.plugin(TokenMeterService)
const session = ctx.sessions.create()
session.append('request/header', {
header: { config: CONFIG, system: 'You are terse.' },
reason: 'initial',
})
appendUser(session, 'abcd')
const checkpoint = JSON.parse(JSON.stringify(
ctx.sessionProjections.checkpoint(session),
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
await meterFiber.dispose()
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextBreakdown')
await ctx.plugin(TokenMeterService)
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual({
systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }),
toolsTokens: 0,
messageTokens: 9,
})
})
})
describe('shared estimator', () => {
it('prices every content-block shape under the fixed heuristic', () => {
expect(estimateContent([{ type: 'text', text: 'abcd' }])).toBe(5)
expect(estimateContent([{ type: 'reasoning', text: 'abcdefgh' }] as ContentBlock[])).toBe(6)
expect(estimateContent([{ type: 'tool-call', id: 'c' as never, name: 'bash', arguments: '{"a":1}' }])).toBe(7)
expect(estimateContent([{
type: 'tool-result', toolCallId: 'c' as never,
content: [{ type: 'text', text: 'abcd' }],
}])).toBe(9)
const unknown = { type: 'mystery', payload: 'abc' } as unknown as ContentBlock
expect(estimateContent([unknown])).toBe(4 + Math.ceil(JSON.stringify(unknown).length / 4))
})
it('prices envelope parts independently and absent parts to zero', () => {
expect(estimateSystemTokens(undefined)).toBe(0)
expect(estimateSystemTokens({ config: CONFIG })).toBe(0)
expect(estimateSystemTokens({ config: CONFIG, system: 'abcdefgh' })).toBe(6)
expect(estimateToolsTokens(undefined)).toBe(0)
expect(estimateToolsTokens({ config: CONFIG, tools: [] })).toBe(0)
expect(estimateToolsTokens({ config: CONFIG, tools: TOOLS }))
.toBe(Math.ceil(JSON.stringify(TOOLS).length / 4) + 4)
expect(estimateHeader(undefined)).toBe(0)
expect(estimateHeader({ config: CONFIG, system: 'abcdefgh', tools: TOOLS }))
.toBe(6 + Math.ceil(JSON.stringify(TOOLS).length / 4) + 4)
})
})

View File

@@ -70,6 +70,26 @@ const projected = (ctx: Context, session: Session): TokenUsageProjection => {
return value
}
/**
* Meter one upcoming replacement the way compact-basic does: price the
* replaced span from the measurement service's own nodes and log the
* shadow-price event directly before the replace.
*/
function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void {
const nodes = ctx.tokenMeter.measure(session).nodes
const startIdx = nodes.findIndex(node => node.seq === start)
const endIdx = nodes.findIndex(node => node.seq === end)
const shadowed = nodes.slice(startIdx, endIdx + 1)
session.append('compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start, end },
shadowedSeqs: shadowed.map(node => node.seq),
shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0),
provider: 'mock',
model: 'mock',
})
}
describe('tokenUsage session projection', () => {
it('serves zero buckets for an empty log', async () => {
const { ctx, session } = await harness()
@@ -184,6 +204,7 @@ describe('tokenUsage session projection', () => {
content: [{ type: 'text', text: 'before compaction' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
appendSummaryMeter(ctx, session, before.seq, before.seq)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted' }],
source: { kind: 'plugin', plugin: 'test' },
@@ -235,6 +256,34 @@ function recordContext(session: Session, model: string, contextWindow?: number):
})
}
/** Append one model-visible user turn and return its surface seq. */
function appendUser(session: Session, text: string): number {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' }).seq
}
/** Append one finalized assistant turn carrying its provider usage. */
function appendAssistant(
session: Session,
text: string,
usage: TokenUsage,
turn: number,
step: number,
): number {
return session.append('assistant/message', {
turn,
step,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage,
}, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
}
describe('contextPressure session projection', () => {
it('serves no pressure or capacity for an empty log', async () => {
const { ctx, session } = await harness()
@@ -277,9 +326,13 @@ describe('contextPressure session projection', () => {
startStep(session, 1, 1)
recordContext(session, 'small', 64_000)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 64_000 })
expect(pressure(ctx, session)).toEqual({
pressureTokens: 100, projectedTokens: 100, contextWindow: 64_000,
})
recordContext(session, 'large', 256_000)
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 256_000 })
expect(pressure(ctx, session)).toEqual({
pressureTokens: 100, projectedTokens: 100, contextWindow: 256_000,
})
})
it('removes an older capacity when the newest route advertises none', async () => {
@@ -288,7 +341,7 @@ describe('contextPressure session projection', () => {
recordContext(session, 'small', 64_000)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
recordContext(session, 'unknown')
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100 })
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, projectedTokens: 100 })
})
it('pushes no change for unrelated events or a restated capacity', async () => {
@@ -319,7 +372,7 @@ describe('contextPressure session projection', () => {
const checkpoint = JSON.parse(JSON.stringify(
ctx.sessionProjections.checkpoint(session),
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
expect(checkpoint.contextPressure?.ver).toBe(2)
expect(checkpoint.contextPressure?.ver).toBe(4)
await meterFiber.dispose()
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure')
@@ -327,7 +380,62 @@ describe('contextPressure session projection', () => {
await ctx.plugin(TokenMeterService)
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextPressure).toEqual({
pressureTokens: 42,
projectedTokens: 42,
contextWindow: 64_000,
})
})
it('carries the sample forward over surface growth and a compaction', async () => {
const { ctx, session } = await harness()
recordContext(session, 'large', 128_000)
const question = appendUser(session, 'a first question worth a few tokens')
startStep(session, 1, 1)
// The provider prices the prompt its request actually carried; the sample
// must anchor against the surface as of that request, not after the
// assistant message joins it.
const answer = appendAssistant(session, 'an answer of some length', { inputTokens: 900, outputTokens: 20 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
const afterTurn = pressure(ctx, session)
expect(afterTurn.pressureTokens).toBe(900)
// The assistant message landed after the sample, so it already shows.
expect(afterTurn.projectedTokens).toBeGreaterThan(900)
const grown = appendUser(session, 'a follow-up question that grows the surface further')
const beforeCompaction = pressure(ctx, session).projectedTokens
expect(beforeCompaction).toBeGreaterThan(afterTurn.projectedTokens!)
// Compaction reports no usage of its own, so `pressureTokens` cannot move;
// the projected figure must shrink anyway — the defect this field fixes.
appendSummaryMeter(ctx, session, question, grown)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'plugin', plugin: 'test' },
}), {
surfaceOp: { op: 'replace', start: question, end: grown },
sourceEventSeqs: [question, answer, grown],
})
const compacted = pressure(ctx, session)
expect(compacted.pressureTokens).toBe(900)
expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!)
})
it('clamps a projection that heuristic error drove below zero', async () => {
const { ctx, session } = await harness()
recordContext(session, 'large', 128_000)
const question = appendUser(session, 'a question long enough to outprice the sample'.repeat(4))
startStep(session, 1, 1)
// A provider sample far below the heuristic price of what it replaced:
// shadowing that span subtracts more than the sample holds.
appendAssistant(session, 'ok', { inputTokens: 3, outputTokens: 1 }, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
appendSummaryMeter(ctx, session, question, question)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '.' }],
source: { kind: 'plugin', plugin: 'test' },
}), {
surfaceOp: { op: 'replace', start: question, end: question },
sourceEventSeqs: [question],
})
expect(pressure(ctx, session).projectedTokens).toBe(0)
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../compact/compact"
},
{
"path": "../../session-projection/session-projection"
},