diff --git a/src/index.ts b/src/index.ts index 927a786..22f424c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ import { } from '@deepseek-ai/dsh-compaction' import { createCore, type CompressionCore } from 'acp-kernel' import type { Agent } from '@deepseek-ai/dsh-agent' +import { DEFAULT_SESSION_CACHE_LIMIT, LruMap } from './lru.ts' import { AcpStateStore } from './state.ts' import { makeTools, type ToolEnvironment } from './tools.ts' import { acpCommand } from './commands.ts' @@ -196,7 +197,7 @@ export class AcpCompactionEngine extends CompactionEngine { */ readonly env: ToolEnvironment - private readonly lastNudgeTurn = new Map() + private readonly lastNudgeTurn = new LruMap(DEFAULT_SESSION_CACHE_LIMIT) /** Per-session emergency-nudge injection budget for the current user turn (issue #108). */ private readonly emergencyNudges = new Map() /** Successful compress call ids awaiting their tool/result so the pair can be hidden. */ diff --git a/src/lru.ts b/src/lru.ts new file mode 100644 index 0000000..17dd462 --- /dev/null +++ b/src/lru.ts @@ -0,0 +1,38 @@ +/** + * A size-capped Map that evicts least-recently-used entries once the cap is + * reached (issue #113). Recency is refreshed by both get and set. Backs the + * engine's per-session caches so idle sessions can be dropped and later + * rebuilt from the durable session log instead of accumulating forever. + * @module billion-context-dsh/lru + */ + +/** Default cap for the engine's per-session caches (kernel states, nudge dedup). */ +export const DEFAULT_SESSION_CACHE_LIMIT = 512 + +export class LruMap extends Map { + private readonly maxEntries: number + + constructor(maxEntries: number) { + super() + this.maxEntries = Math.max(1, Math.floor(maxEntries)) + } + + get(key: K): V | undefined { + if (!super.has(key)) return undefined + const value = super.get(key)! + super.delete(key) + super.set(key, value) + return value + } + + set(key: K, value: V): this { + super.delete(key) + super.set(key, value) + while (this.size > this.maxEntries) { + const oldest = this.keys().next().value + if (oldest === undefined) break + super.delete(oldest) + } + return this + } +} diff --git a/src/state.ts b/src/state.ts index 47c5850..1b8f405 100644 --- a/src/state.ts +++ b/src/state.ts @@ -19,6 +19,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { createInitialState, type CompressionBlock, type CompressionState } from 'acp-kernel' +import { DEFAULT_SESSION_CACHE_LIMIT, LruMap } from './lru.ts' import { rebuildBlockLedger } from './region.ts' import { sessionEventsOf } from './session-events.ts' @@ -97,8 +98,32 @@ function nextBlockIdAfter(events: readonly SessionEvent[]): number { return max + 1 } +/** The next kernel run id after the rehydrated blocks (or the initial 1). */ +function nextRunIdAfter(blocks: readonly CompressionBlock[]): number { + let max = 0 + for (const block of blocks) { + const num = Number(block.runId.slice(1)) + if (Number.isInteger(num)) max = Math.max(max, num) + } + return max + 1 +} + export class AcpStateStore { - private readonly states = new Map() + /** + * Live kernel states, capped by an LRU policy (issue #113): once the cap is + * reached the coldest session's state is dropped, and its next access + * rehydrates through stateFor's log-rebuild path below. Rehydration is + * deterministic — bN ids are recorded in the durable event or synthesised + * in ledger order, and run ids continue after the rehydrated max — so block + * identity survives eviction exactly as it survives a restart. Kernel + * fields that reset on eviction (tokenSnapshot, nudge cadence, stats + * counters) all self-heal on the session's next turn. + */ + private readonly states: LruMap + + constructor(limit: number = DEFAULT_SESSION_CACHE_LIMIT) { + this.states = new LruMap(limit) + } /** Kernel state for one session, initialised on first access. */ stateFor(session: Session): CompressionState { @@ -110,6 +135,7 @@ export class AcpStateStore { if (events.some((event) => event.type === 'compaction/summary')) { state.blocks = rebuildKernelBlocks(events) state.nextBlockId = nextBlockIdAfter(events) + state.nextRunId = nextRunIdAfter(state.blocks) } this.states.set(id, state) return state diff --git a/tests/lru.test.ts b/tests/lru.test.ts new file mode 100644 index 0000000..5958220 --- /dev/null +++ b/tests/lru.test.ts @@ -0,0 +1,51 @@ +/** + * LruMap unit tests: cap enforcement, recency refresh on get AND set, + * iteration order = recency order (issue #113). + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { LruMap } from '../src/lru.ts' + +test('LRU: evicts the least recently used entry when over the cap', () => { + const map = new LruMap(3) + map.set('a', 1) + map.set('b', 2) + map.set('c', 3) + assert.equal(map.get('a'), 1, 'get returns the value') + map.set('d', 4) + assert.equal(map.size, 3) + assert.equal(map.has('a'), true, 'the just-read entry survived') + assert.equal(map.has('b'), false, 'the coldest entry was evicted') + assert.equal(map.has('c'), true) + assert.equal(map.has('d'), true) +}) + +test('LRU: re-setting a key refreshes its recency', () => { + const map = new LruMap(2) + map.set('a', 1) + map.set('b', 2) + map.set('a', 10) + map.set('c', 3) + assert.deepEqual([...map.keys()], ['a', 'c'], 're-set moved a ahead of b; b evicted as coldest') + assert.equal(map.has('b'), false) + assert.equal(map.get('a'), 10) +}) + +test('LRU: delete removes without evicting other entries', () => { + const map = new LruMap(2) + map.set('a', 1) + map.set('b', 2) + assert.equal(map.delete('a'), true) + map.set('c', 3) + assert.deepEqual([...map.keys()], ['b', 'c']) + assert.equal(map.delete('missing'), false) +}) + +test('LRU: clamps the cap to at least one entry', () => { + const map = new LruMap(0) + map.set('a', 1) + assert.equal(map.size, 1) + map.set('b', 2) + assert.deepEqual([...map.keys()], ['b']) +}) diff --git a/tests/state.test.ts b/tests/state.test.ts index 00efe34..de6178e 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -9,12 +9,13 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { Context } from '@deepseek-ai/cordis' import { createCore, type CompressionCore } from 'acp-kernel' +import { Session } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { AcpStateStore } from '../src/state.ts' import { makeTools, type ToolEnvironment } from '../src/tools.ts' import { rebuildBlockLedger } from '../src/region.ts' -import { buildTextSession } from './helpers.ts' +import { appendAssistant, appendTurn, appendUser, buildTextSession, longText } from './helpers.ts' function makeEnv(limit = 128000): ToolEnvironment { return { @@ -128,3 +129,41 @@ test('M2: block topic persists through the log — the acp_status block title su const plainState = new AcpStateStore().stateFor(plain) assert.equal(plainState.blocks[0]!.topic, undefined) }) + +function buildNamedTextSession(id: string, count: number): Session { + const session = Session.create(id) + appendTurn(session, 1) + for (let index = 0; index < count; index += 1) { + if (index % 2 === 0) appendUser(session, longText('msg', index)) + else appendAssistant(session, longText('reply', index), 1, index) + } + return session +} + +test('M2: store cap evicts cold sessions losslessly — rehydration reproduces identical block identity', async () => { + const env = makeEnv() + const cold = buildNamedTextSession('lru-cold', 12) + const warm = buildNamedTextSession('lru-warm', 12) + const compress = toolOf(env, 'compress') + await compress.execute({ content: [{ startSeq: 1, endSeq: 5, summary: TIER_SUMMARY }] } as never, fakeExec(cold)) + await compress.execute({ content: [{ startSeq: 1, endSeq: 5, summary: TIER_SUMMARY }] } as never, fakeExec(warm)) + + // A cap of ONE keeps only the most recently used session's live state. + const capped = new AcpStateStore(1) + const before = capped.stateFor(cold) + assert.equal(before.blocks.length, 1) + assert.equal(before.blocks[0]!.blockId, 'b1') + assert.equal(before.nextBlockId, 2) + assert.equal(before.nextRunId, 2) + capped.stateFor(warm) + + // Cold's next access rehydrates from its log: same bN numbering and the + // same continuation counters — indistinguishable from the live state. + const after = capped.stateFor(cold) + assert.notEqual(after, before, 'the old live state was actually evicted') + assert.equal(after.blocks.length, 1) + assert.equal(after.blocks[0]!.blockId, 'b1') + assert.deepEqual(after.blocks[0]!.effectiveMessageIds, before.blocks[0]!.effectiveMessageIds) + assert.equal(after.nextBlockId, 2) + assert.equal(after.nextRunId, 2, 'run ids continue after the rehydrated max, not back at r1') +})