From 59df683ef137de126f267a00fce7e05dea3260f8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:50:38 +0800 Subject: [PATCH] fix(token-meter): bound projection state via logged shadow prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contextBreakdown and contextPressure units carried the full priced surface, so each session's persisted projection checkpoint grew without bound. A surface replacement is now priced by the shadow-price event logged directly before it — compact/summary for compaction, the new compact/prune from tool-result pruning (priced through the injected token meter) — and the unit states shrink to a fixed handful of numbers. Regenerate the persistence/cordis/module/config catalogs. --- docs/config-catalog.md | 2 + docs/cordis-catalog/services.md | 7 +- docs/module-graph.md | 29 ++++--- docs/persistence-catalog.md | 35 +++++++- .../compact-tool-result-prune/package.json | 4 + .../compact-tool-result-prune/src/index.ts | 21 ++++- .../tests/loader-composition.spec.ts | 12 ++- .../tests/tool-result-prune.spec.ts | 23 ++++- .../compact-tool-result-prune/tsconfig.json | 2 + packages/compact/compact/src/types.ts | 24 +++++- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/token-meter/package.json | 2 + .../token-meter/src/breakdown-projection.ts | 43 ++++++---- packages/llm/token-meter/src/invariant.ts | 8 +- packages/llm/token-meter/src/surface-fold.ts | 13 +-- .../llm/token-meter/src/surface-projection.ts | 84 +++++++++++++++++++ .../llm/token-meter/src/usage-projection.ts | 55 +++++++----- .../context-breakdown-projection.spec.ts | 81 ++++++++++++++++-- .../tests/token-usage-projection.spec.ts | 25 +++++- packages/llm/token-meter/tsconfig.json | 3 + pnpm-lock.yaml | 9 ++ 21 files changed, 403 insertions(+), 81 deletions(-) create mode 100644 packages/llm/token-meter/src/surface-projection.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d28427e531..f7bee55687 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -405,6 +405,8 @@ Source: [`packages/compact/compact-basic/src/types.ts:38`](../packages/compact/c ## `@deepseek-ai/dsh-compact-tool-result-prune` +Requires: `tokenMeter` + ```ts config-catalog /** Character-budget policy for deterministic tool-result pruning. */ export interface ToolResultPruneConfig { diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2c34e4fc78..705df2b7c4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2358,7 +2358,10 @@ pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null /** * Prune every over-budget tool result from one stable current-surface snapshot. * Each replacement preserves the complete event data except for `content`, - * and points at the shadowed node for durable provenance and replay. + * points at the shadowed node for durable provenance and replay, and is + * immediately preceded by a `compact/prune` shadow-price event pricing the + * shadowed node through the injected token meter, so pure consumers can + * subtract it without per-node state. * @param session - session whose current surface is rewritten. * @returns landed replacements and aggregate Unicode-code-point savings. * @throws when the session rejects a replacement; replacements committed @@ -2369,7 +2372,7 @@ pruneSession(session: Session): PruneResult Types: [ContentBlock](../core-data-structures/core.md) · [PruneResult](../core-data-structures/compaction.md) · [Session](../core-data-structures/session.md) -Source: [`packages/compact/compact-tool-result-prune/src/index.ts:40`](../../packages/compact/compact-tool-result-prune/src/index.ts) +Source: [`packages/compact/compact-tool-result-prune/src/index.ts:44`](../../packages/compact/compact-tool-result-prune/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/module-graph.md b/docs/module-graph.md index 8ead055d4b..0e2e7e0c37 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -428,9 +428,6 @@ flowchart TD pkg_compact --> pkg_invariants pkg_compact --> pkg_llm pkg_compact --> pkg_session - pkg_compact_tool_result_prune --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_llm - pkg_compact_tool_result_prune --> pkg_session pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web @@ -490,6 +487,7 @@ flowchart TD pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout + pkg_token_meter --> pkg_compact pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session @@ -626,13 +624,11 @@ flowchart TD pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_compact_tool_result_prune - pkg_compact_basic --> pkg_invariants - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session - pkg_compact_basic --> pkg_token_meter + pkg_compact_tool_result_prune --> pkg_compact + pkg_compact_tool_result_prune --> pkg_invariants + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session + pkg_compact_tool_result_prune --> pkg_token_meter pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -728,6 +724,13 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune + pkg_compact_basic --> pkg_invariants + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants @@ -1138,7 +1141,6 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1155,7 +1157,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1188,7 +1190,7 @@ flowchart TD | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | @@ -1204,6 +1206,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a0790be7ad..ca61d82caf 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -236,7 +236,31 @@ Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/in 'compact/end': { turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:51`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact/src/types.ts) + +#### `compact/prune` — log-only + +```ts persistence-catalog +/** + * Shadow price of one model-free prune replacement — log-only, no + * surfaceOp. The shared shadow-price protocol: a surface `replace` event + * is priced by the metering event immediately before it (`compact/summary` + * for a summarizing compaction, this event for a prune), which states the + * heuristic token price of the exact replaced range so a pure consumer + * can subtract it without retaining per-node prices. The replacement MUST + * be appended synchronously right after this event. + */ +'compact/prune': { + /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */ + shadowedRange: { start: number; end: number } + /** The seqs of all shadowed surface nodes, in surface order. */ + shadowedSeqs: number[] + /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */ + shadowedTokenCount: number +} +``` + +Source: [`packages/compact/compact/src/types.ts:64`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -257,8 +281,11 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact /** * Provenance record of a completed summarization — log-only, no surfaceOp. * The summary content is in `data.summary`; the actual surface replacement - * is performed by a subsequent `user/message` event that shadows the - * compacted range. + * is performed by the immediately following `user/message` event that + * shadows the compacted range. That adjacency is contractual — the + * shadowed pricing fields are the replacement's shadow price, so a + * consumer may pair a replacement with the metering event directly + * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { summary: ContentBlock[] @@ -285,7 +312,7 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:26`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts) ### `goal/*` diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index f3b0945460..bc38eccdb0 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -25,9 +25,11 @@ ], "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", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -36,9 +38,11 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/compact-tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts index 0fbf7bcac0..f783cdd59d 100644 --- a/packages/compact/compact-tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -9,6 +9,10 @@ import z from 'schemastery' import { freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session' +// Type-only: the `compact/*` SessionEventMap merges (the shadow-price event). +import type {} from '@deepseek-ai/dsh-compact' +// Type-only: the `ctx.tokenMeter` Context merge for the declared injection. +import type {} from '@deepseek-ai/dsh-token-meter' import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts' import type { PrunedEntry, @@ -38,6 +42,10 @@ interface SnapshotCandidate { /** Deterministic head/middle/tail pruning for current tool-result surface nodes. */ export class ToolResultPruneService extends Service { + // The token meter prices each shadowed node for its logged shadow-price + // event, so pruning genuinely requires the pricing capability. + static inject = ['tokenMeter'] + static Config: z = z.object({ thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars), headChars: z.number().step(1).min(0).default(DEFAULTS.headChars), @@ -116,7 +124,10 @@ export class ToolResultPruneService extends Service { /** * Prune every over-budget tool result from one stable current-surface snapshot. * Each replacement preserves the complete event data except for `content`, - * and points at the shadowed node for durable provenance and replay. + * points at the shadowed node for durable provenance and replay, and is + * immediately preceded by a `compact/prune` shadow-price event pricing the + * shadowed node through the injected token meter, so pure consumers can + * subtract it without per-node state. * @param session - session whose current surface is rewritten. * @returns landed replacements and aggregate Unicode-code-point savings. * @throws when the session rejects a replacement; replacements committed @@ -145,6 +156,14 @@ export class ToolResultPruneService extends Service { content, }] as [typeof result], }) + // Shadow-price protocol: the metering event and its replacement are + // appended synchronously adjacent, so pure consumers subtract the + // shadowed node's heuristic price without retaining per-node state. + session.append('compact/prune', { + shadowedRange: { start: seq, end: seq }, + shadowedSeqs: [seq], + shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message), + }) const replacement = session.append('tool/result', { ...event.data, message, diff --git a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts index db4c29ebdb..fbc4b840c9 100644 --- a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' let root: string | undefined @@ -23,6 +24,7 @@ describe('compact-tool-result-prune real Loader composition', () => { root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-token-meter'", "- name: '@deepseek-ai/dsh-compact-tool-result-prune'", ' config:', ' thresholdChars: 100', @@ -38,10 +40,9 @@ describe('compact-tool-result-prune real Loader composition', () => { context.loader.internal = { version: 'v2', async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') { - throw new Error(`unexpected Loader import: ${specifier}`) - } - return ToolResultPruneService + if (specifier === '@deepseek-ai/dsh-token-meter') return TokenMeterService + if (specifier === '@deepseek-ai/dsh-compact-tool-result-prune') return ToolResultPruneService + throw new Error(`unexpected Loader import: ${specifier}`) }, } as unknown as NonNullable await context.loader.create({ @@ -60,6 +61,9 @@ describe('compact-tool-result-prune real Loader composition', () => { it('rejects stale config after plugin schema normalization', async () => { context = new Context() + // Satisfy the declared injection first: config normalization runs in the + // service constructor, which a pending fiber never reaches. + await context.plugin(TokenMeterService) await expect(context.plugin(ToolResultPruneService, { maxChars: 100, } as never)).rejects.toThrow(/unknown key "maxChars"/) diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index 9eadf57a66..c5c7171c08 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -9,6 +9,7 @@ import SessionStore, { import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService, { codePointLength, DEFAULTS, @@ -25,9 +26,16 @@ const SMALL: ToolResultPruneConfig = { } function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService { - return new ToolResultPruneService(new Context(), config) + const ctx = new Context() + // Service constructors self-register, so `ctx.tokenMeter` resolves for the + // shadow-price pricing without a full plugin boot. + void new TokenMeterService(ctx) + return new ToolResultPruneService(ctx, config) } +/** Pricing oracle mirroring the service's estimator for expectations. */ +const METER = new TokenMeterService(new Context()) + function appendToolStep( session: Session, turn: number, @@ -203,6 +211,18 @@ describe('ToolResultPruneService session transaction', () => { sourceEventSeqs: [originalSeq], }) expect(session.surface.nodes).not.toContain(originalSeq) + + // Shadow-price protocol: the metering event sits directly before the + // replacement and prices the shadowed node with the shared estimator. + if (original.type !== 'tool/result') throw new Error('original is not a tool/result') + expect(session.events[entry.replacementSeq - 1]).toMatchObject({ + type: 'compact/prune', + data: { + shadowedRange: { start: originalSeq, end: originalSeq }, + shadowedSeqs: [originalSeq], + shadowedTokenCount: METER.estimateMessage(original.data.message), + }, + }) }) it('prunes multiple results, skips short ones, and converges in one pass', () => { @@ -240,6 +260,7 @@ describe('ToolResultPruneService session transaction', () => { await ctx.plugin(SessionStore) await ctx.plugin(InvariantService) await ctx.plugin(SessionInvariant) + await ctx.plugin(TokenMeterService) const prune = new ToolResultPruneService(ctx, SMALL) const session = ctx.sessions.create(SessionId('invariants')) appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) diff --git a/packages/compact/compact-tool-result-prune/tsconfig.json b/packages/compact/compact-tool-result-prune/tsconfig.json index a6c2e5124b..be5cb5b66c 100644 --- a/packages/compact/compact-tool-result-prune/tsconfig.json +++ b/packages/compact/compact-tool-result-prune/tsconfig.json @@ -10,7 +10,9 @@ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, + { "path": "../../llm/token-meter" }, { "path": "../../core/session" }, + { "path": "../compact" }, { "path": "../../support/invariants" } ] } diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 173366bc4b..4a856a6848 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -20,8 +20,11 @@ declare module '@deepseek-ai/dsh-session' { /** * Provenance record of a completed summarization — log-only, no surfaceOp. * The summary content is in `data.summary`; the actual surface replacement - * is performed by a subsequent `user/message` event that shadows the - * compacted range. + * is performed by the immediately following `user/message` event that + * shadows the compacted range. That adjacency is contractual — the + * shadowed pricing fields are the replacement's shadow price, so a + * consumer may pair a replacement with the metering event directly + * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { summary: ContentBlock[] @@ -49,6 +52,23 @@ declare module '@deepseek-ai/dsh-session' { * matches `compact/start`; `error` records an unsuccessful attempt. */ 'compact/end': { turn: number | null; error?: string } + /** + * Shadow price of one model-free prune replacement — log-only, no + * surfaceOp. The shared shadow-price protocol: a surface `replace` event + * is priced by the metering event immediately before it (`compact/summary` + * for a summarizing compaction, this event for a prune), which states the + * heuristic token price of the exact replaced range so a pure consumer + * can subtract it without retaining per-node prices. The replacement MUST + * be appended synchronously right after this event. + */ + 'compact/prune': { + /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */ + shadowedRange: { start: number; end: number } + /** The seqs of all shadowed surface nodes, in surface order. */ + shadowedSeqs: number[] + /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */ + shadowedTokenCount: number + } } } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d7f36e7d81..fde00f8bae 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1058,7 +1058,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'pruneSession(session: Session): PruneResult', - jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */', + jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * points at the shadowed node for durable provenance and replay, and is\n * immediately preceded by a `compact/prune` shadow-price event pricing the\n * shadowed node through the injected token meter, so pure consumers can\n * subtract it without per-node state.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */', }, ], }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 80c0d99c8a..5670fe97cd 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -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:^", diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 3634172c9b..036f80647f 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -6,11 +6,11 @@ */ import { z } from 'zod' -import { canonicalHeader, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type { TokenSurfaceNode } from './types.ts' import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts' -import { foldSurfaceTokens } from './surface-fold.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' @@ -18,8 +18,8 @@ interface ContextBreakdownState { systemTokens: number toolsTokens: number messageTokens: number - /** Priced surface nodes (plain JSON for the persisted projection cache). */ - surface: TokenSurfaceNode[] + /** Shadow price armed by the immediately preceding metering event. */ + claim?: ShadowPriceClaim } const breakdownSchema = z.object({ @@ -32,31 +32,38 @@ const breakdownSchema = z.object({ * Token-meter's context-composition projection unit. * * Envelope figures are last-wins per `request/header`; the message figure - * rides {@link foldSurfaceTokens} — the same fold the measurement service - * replays — so it equals `measure().surfaceTokens` at every event boundary and - * compaction shrinks it the way it shrinks the next request. + * 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, surface: [] }), + 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) - const systemTokens = estimateSystemTokens(header) - const toolsTokens = estimateToolsTokens(header) - if (systemTokens === state.systemTokens && toolsTokens === state.toolsTokens) return state - return { ...state, systemTokens, toolsTokens } + systemTokens = estimateSystemTokens(header) + toolsTokens = estimateToolsTokens(header) } - if (!isSurfaceEvent(event)) return state - const fold = foldSurfaceTokens(state.surface, event) + if (systemTokens === state.systemTokens + && toolsTokens === state.toolsTokens + && fold.deltaTokens === 0 + && fold.claim === undefined + && state.claim === undefined) return state return { - ...state, + systemTokens, + toolsTokens, messageTokens: state.messageTokens + fold.deltaTokens, - surface: fold.nodes, + ...fold.claim === undefined ? {} : { claim: fold.claim }, } }, view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }), - stateVersion: 1, + stateVersion: 2, } diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 34a0048e8f..53ae13c466 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -20,9 +20,11 @@ export const inject = ['invariants'] * 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 shares `surface-fold.ts` with the measurement service, - * which makes its message figure equal `measure().surfaceTokens` by - * construction rather than by a relation worth observing at runtime. + * 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 = () => {} diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index cc52bc3cce..e4dfacc254 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -1,10 +1,11 @@ /** - * The one positional surface fold, shared by the measurement service's replay - * state and the pure `contextBreakdown` projection. Both answer "what does the - * current model-visible conversation cost", so they MUST price and place every - * node identically: a private copy in either owner would let the panel's - * message figure drift away from `measure().surfaceTokens` with both sides - * still passing their own tests. + * 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 */ diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts new file mode 100644 index 0000000000..dcc8181370 --- /dev/null +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -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 } +} diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 371d5aeebb..b83e761524 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -4,12 +4,11 @@ import { z } from 'zod' import type { TokenUsage } from '@deepseek-ai/dsh-llm' -import { isSurfaceEvent } from '@deepseek-ai/dsh-session' 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 type { TokenSurfaceNode } from './types.ts' -import { foldSurfaceTokens } from './surface-fold.ts' +import { foldSurfaceProjection } from './surface-projection.ts' +import type { ShadowPriceClaim } from './surface-projection.ts' interface UsageSample { turn: number @@ -82,19 +81,25 @@ const usageOf = (event: SessionEvent): TokenUsage | undefined => /** * Context-occupancy state: the two independent last-wins records plus the - * priced surface needed to carry the newest sample forward. + * O(1) running surface total needed to carry the newest sample forward. */ interface ContextPressureState { contextWindow?: number pressureTokens?: number - /** Priced surface, folded identically to the measurement service's. */ - surface: TokenSurfaceNode[] - /** Summed heuristic tokens over {@link surface}. */ + /** 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 } +/** Whether two optional shadow-price claims price the same range identically. */ +const claimEquals = (left: ShadowPriceClaim | undefined, right: ShadowPriceClaim | undefined): boolean => + left === right + || (left !== undefined && right !== undefined + && left.start === right.start && left.end === right.end && left.tokens === right.tokens) + /** * Token-meter's session projection unit. * @@ -152,26 +157,33 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { * `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 the priced surface alongside it and publishes + * 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. A usage sample is stamped BEFORE the same event joins the surface, so - * an `assistant/message` anchors against the surface its own request saw. + * 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', ContextPressureState> = { key: 'contextPressure', schema: pressureSchema, - init: () => ({ surface: [], surfaceTokens: 0 }), + 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 + } + } } - let next = state const usage = usageOf(event) if (usage !== undefined) { const pressureTokens = pressureFrom(usage) @@ -179,9 +191,12 @@ ProjectionDefinition<'contextPressure', ContextPressureState> = { next = { ...next, pressureTokens, sampledSurfaceTokens: next.surfaceTokens } } } - if (!isSurfaceEvent(event)) return next - const fold = foldSurfaceTokens(next.surface, event) - return { ...next, surface: fold.nodes, surfaceTokens: next.surfaceTokens + fold.deltaTokens } + if (fold.deltaTokens !== 0) { + next = { ...next, surfaceTokens: next.surfaceTokens + fold.deltaTokens } + } + if (claimEquals(state.claim, fold.claim)) return next + const { claim: _expired, ...withoutClaim } = next + return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim } }, view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({ ...contextWindow === undefined ? {} : { contextWindow }, @@ -190,5 +205,5 @@ ProjectionDefinition<'contextPressure', ContextPressureState> = { ? {} : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) }, }), - stateVersion: 3, + stateVersion: 4, } diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 88eb854a63..b7e4850fd4 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -48,6 +48,26 @@ function appendUser(session: Session, text: string): number { }), { 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() @@ -100,7 +120,7 @@ describe('contextBreakdown session projection', () => { expect(projected(ctx, session).messageTokens).toBe(9) }) - it('shrinks the message figure when a replacement compacts the surface', async () => { + 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') @@ -108,6 +128,7 @@ describe('contextBreakdown session projection', () => { 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], @@ -146,6 +167,9 @@ describe('contextBreakdown session projection', () => { 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' }, @@ -156,7 +180,7 @@ describe('contextBreakdown session projection', () => { expect(agree()).toBeLessThan(grown) }) - it('fails loud on a replace range absent from the folded surface', () => { + 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', @@ -173,12 +197,59 @@ describe('contextBreakdown session projection', () => { 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)) - expect(() => definition.apply(state, replace(7, 3))).toThrow('invalid current range') - expect(() => definition.apply(state, replace(1, 7))).toThrow('invalid current range') - expect(() => definition.apply(state, replace(3, 1))).toThrow('invalid current range') + // 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).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 () => { diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index bf1a04e679..07261576eb 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -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' }, @@ -351,7 +372,7 @@ describe('contextPressure session projection', () => { const checkpoint = JSON.parse(JSON.stringify( ctx.sessionProjections.checkpoint(session), )) as ReturnType - expect(checkpoint.contextPressure?.ver).toBe(3) + expect(checkpoint.contextPressure?.ver).toBe(4) await meterFiber.dispose() expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure') @@ -385,6 +406,7 @@ describe('contextPressure session projection', () => { // 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' }, @@ -406,6 +428,7 @@ describe('contextPressure session projection', () => { // 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' }, diff --git a/packages/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json index 92081a860b..b8b32e36fa 100644 --- a/packages/llm/token-meter/tsconfig.json +++ b/packages/llm/token-meter/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../compact/compact" + }, { "path": "../../session-projection/session-projection" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b15d260058..d492f3b440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2363,6 +2363,9 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2372,6 +2375,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -3857,6 +3863,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants