Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -196,7 +197,7 @@ export class AcpCompactionEngine extends CompactionEngine {
*/
readonly env: ToolEnvironment

private readonly lastNudgeTurn = new Map<string, number>()
private readonly lastNudgeTurn = new LruMap<string, number>(DEFAULT_SESSION_CACHE_LIMIT)
/** Per-session emergency-nudge injection budget for the current user turn (issue #108). */
private readonly emergencyNudges = new Map<string, { turn: number; count: number }>()
/** Successful compress call ids awaiting their tool/result so the pair can be hidden. */
Expand Down
38 changes: 38 additions & 0 deletions src/lru.ts
Original file line number Diff line number Diff line change
@@ -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<K, V> extends Map<K, V> {
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
}
}
28 changes: 27 additions & 1 deletion src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string, CompressionState>()
/**
* 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<string, CompressionState>

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 {
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/lru.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>(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<string, number>(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<string, number>(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<string, number>(0)
map.set('a', 1)
assert.equal(map.size, 1)
map.set('b', 2)
assert.deepEqual([...map.keys()], ['b'])
})
41 changes: 40 additions & 1 deletion tests/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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')
})
Loading