fix(token-meter): bound projection state via logged shadow prices
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.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ToolResultPruneConfig> = 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,
|
||||
|
||||
@@ -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<typeof context.loader.internal>
|
||||
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"/)
|
||||
|
||||
@@ -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) }])
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user