From 65ada9cb7abd79afa307443502f59b33fbef436f Mon Sep 17 00:00:00 2001 From: jamby77 Date: Wed, 17 Jun 2026 12:27:30 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(agent-memory):=20Phase=205=20=E2=80=94?= =?UTF-8?q?=20eviction=20&=20TTL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add selectEvictions(): pure lowest-(importance, recency) selection, reusing the recall weights minus similarity, renormalized - Extract recencyDecay() from compositeScore for shared half-life decay - remember(): write expiring memories atomically via MULTI/HSET/EXPIRE/EXEC when ttl is set; durable HSET otherwise - Enforce maxItemsPerScope on write (count-first probe, then bounded candidate scan) and record evictions in __mem_stats; best-effort so it never breaks the write path - Add ttl to RememberOptions and maxItemsPerScope to MemoryStoreOptions --- packages/agent-memory/src/MemoryStore.ts | 99 ++++++++++- .../__tests__/MemoryStore.eviction.test.ts | 155 ++++++++++++++++++ .../src/__tests__/selectEvictions.test.ts | 65 ++++++++ packages/agent-memory/src/compositeScore.ts | 7 +- packages/agent-memory/src/selectEvictions.ts | 50 ++++++ packages/agent-memory/src/types.ts | 1 + 6 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts create mode 100644 packages/agent-memory/src/__tests__/selectEvictions.test.ts create mode 100644 packages/agent-memory/src/selectEvictions.ts diff --git a/packages/agent-memory/src/MemoryStore.ts b/packages/agent-memory/src/MemoryStore.ts index 0367d06f..45f8e612 100644 --- a/packages/agent-memory/src/MemoryStore.ts +++ b/packages/agent-memory/src/MemoryStore.ts @@ -4,6 +4,7 @@ import { buildMemoryRecord } from './buildMemoryRecord'; import { buildRecallQuery, buildScopeFilter, SCORE_FIELD } from './buildRecallQuery'; import { parseMemoryItem } from './parseMemoryItem'; import { compositeScore, similarityFromDistance, type RecallWeights } from './compositeScore'; +import { selectEvictions, type EvictionCandidate } from './selectEvictions'; import type { EmbedFn, MemoryHit, @@ -20,6 +21,7 @@ const DEFAULT_RECALL_K = 8; const RECALL_OVERFETCH = 4; const FORGET_BATCH_SIZE = 500; const FORGET_MAX_BATCHES = 10000; +const EVICTION_SCAN_LIMIT = 10000; export interface MemoryStoreOptions { client: MemoryStoreClient; @@ -28,6 +30,7 @@ export interface MemoryStoreOptions { defaultThreshold?: number; weights?: RecallWeights; halfLifeSeconds?: number; + maxItemsPerScope?: number; } export class MemoryStore { @@ -37,6 +40,7 @@ export class MemoryStore { private readonly defaultThreshold: number; private readonly weights: RecallWeights; private readonly halfLifeSeconds: number; + private readonly maxItemsPerScope?: number; private dims?: number; constructor(options: MemoryStoreOptions) { @@ -46,6 +50,7 @@ export class MemoryStore { this.defaultThreshold = options.defaultThreshold ?? DEFAULT_THRESHOLD; this.weights = options.weights ?? DEFAULT_WEIGHTS; this.halfLifeSeconds = options.halfLifeSeconds ?? DEFAULT_HALF_LIFE_SECONDS; + this.maxItemsPerScope = options.maxItemsPerScope; } async recall(query: string, options: RecallOptions = {}): Promise { @@ -192,10 +197,94 @@ export class MemoryStore { const id = randomUUID(); const now = Date.now(); const record = buildMemoryRecord(this.name, id, content, vector, options, now); - await this.client.call('HSET', record.key, ...record.fields); + await this.writeRecord(record.key, record.fields, options.ttl); + // Capacity enforcement is best-effort: the memory is already durably stored, + // so a failed eviction pass must not reject an otherwise successful write. + await this.enforceCapacity(options, now).catch(() => undefined); return id; } + private async writeRecord(key: string, fields: (string | Buffer)[], ttl?: number): Promise { + if (ttl === undefined || ttl <= 0) { + await this.client.call('HSET', key, ...fields); + return; + } + // Set the hash and its expiry in one transaction so a crash between the two + // can't leave a memory that should expire living forever. + await this.client.call('MULTI'); + await this.client.call('HSET', key, ...fields); + await this.client.call('EXPIRE', key, String(ttl)); + await this.client.call('EXEC'); + } + + private async enforceCapacity(scope: MemoryScope, now: number): Promise { + const max = this.maxItemsPerScope; + if (max === undefined) { + return; + } + // Count-first so the common in-capacity write pays only a cheap LIMIT 0 0 + // probe and never fetches candidate rows. + const filter = buildScopeFilter(scope, []); + const countRaw = await this.client.call( + 'FT.SEARCH', + `${this.name}:mem:idx`, + filter, + 'LIMIT', + '0', + '0', + 'DIALECT', + '2', + ); + const total = ftSearchTotal(countRaw); + if (total <= max) { + return; + } + + // Eviction selection is exact while the scope fits EVICTION_SCAN_LIMIT (the + // expected case); a larger scope evicts from the scanned window and the + // remainder is reclaimed on subsequent writes. + + const raw = await this.client.call( + 'FT.SEARCH', + `${this.name}:mem:idx`, + filter, + 'RETURN', + '2', + 'importance', + 'last_accessed_at', + 'LIMIT', + '0', + String(EVICTION_SCAN_LIMIT), + 'DIALECT', + '2', + ); + const candidates: EvictionCandidate[] = parseFtSearchResponse(raw).map((hit) => { + const importance = Number(hit.fields.importance); + const lastAccessedAt = Number(hit.fields.last_accessed_at); + return { + key: hit.key, + importance: Number.isFinite(importance) ? importance : 0, + lastAccessedAt: Number.isFinite(lastAccessedAt) ? lastAccessedAt : 0, + }; + }); + const dropCount = Math.min(total - max, candidates.length); + const evictKeys = selectEvictions(candidates, candidates.length - dropCount, { + now, + halfLifeSeconds: this.halfLifeSeconds, + weights: this.weights, + }); + if (evictKeys.length === 0) { + return; + } + await this.client.call('DEL', ...evictKeys); + await this.client.call( + 'HINCRBY', + `${this.name}:__mem_stats`, + 'evictions', + String(evictKeys.length), + ); + } + private async embed(content: string): Promise { const vector = await this.embedFn(content); if (this.dims === undefined) { @@ -208,3 +297,11 @@ export class MemoryStore { return vector; } } + +function ftSearchTotal(raw: unknown): number { + if (!Array.isArray(raw) || raw.length < 1) { + return 0; + } + const total = typeof raw[0] === 'string' ? parseInt(raw[0], 10) : Number(raw[0]); + return Number.isFinite(total) && total > 0 ? total : 0; +} diff --git a/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts b/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts new file mode 100644 index 00000000..45c2b67e --- /dev/null +++ b/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { MemoryStore } from '../MemoryStore'; +import { fakeEmbed } from './helpers/fakeEmbed'; +import { mockClient } from './helpers/mockClient'; + +function fields(importance: number, lastAccessedAt: number): Record { + return { importance: String(importance), last_accessed_at: String(lastAccessedAt) }; +} + +function searchReply(total: number, hits: Array<[string, Record]> = []): unknown[] { + const out: unknown[] = [String(total)]; + for (const [key, fieldMap] of hits) { + const flat: string[] = []; + for (const [field, value] of Object.entries(fieldMap)) { + flat.push(field, value); + } + out.push(key, flat); + } + return out; +} + +describe('MemoryStore TTL writes', () => { + it('writes a durable memory with a plain HSET when no ttl is given', async () => { + const client = mockClient(() => 'OK'); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await store.remember('durable'); + + const commands = client.call.mock.calls.map((c) => c[0]); + expect(commands).toContain('HSET'); + expect(commands).not.toContain('EXPIRE'); + expect(commands).not.toContain('MULTI'); + }); + + it('writes an expiring memory atomically when ttl is set', async () => { + const client = mockClient(() => 'OK'); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await store.remember('temporary', { ttl: 3600 }); + + const commands = client.call.mock.calls.map((c) => c[0]); + expect(commands).toEqual(['MULTI', 'HSET', 'EXPIRE', 'EXEC']); + const hset = client.call.mock.calls.find((c) => c[0] === 'HSET'); + const expire = client.call.mock.calls.find((c) => c[0] === 'EXPIRE'); + expect(expire?.[1]).toBe(hset?.[1]); + expect(expire?.[2]).toBe('3600'); + }); + + it('treats a non-positive ttl as durable (no EXPIRE)', async () => { + const client = mockClient(() => 'OK'); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await store.remember('x', { ttl: 0 }); + + const commands = client.call.mock.calls.map((c) => c[0]); + expect(commands).toContain('HSET'); + expect(commands).not.toContain('EXPIRE'); + }); +}); + +describe('MemoryStore capacity eviction', () => { + it('evicts the lowest-ranked item and bumps the eviction counter when over capacity', async () => { + const client = mockClient((command, ...args) => { + if (command === 'FT.SEARCH') { + if (args.includes('RETURN')) { + return searchReply(3, [ + ['mem:mem:a', fields(0.1, 1000)], + ['mem:mem:b', fields(0.9, 5000)], + ['mem:mem:c', fields(0.5, 9000)], + ]); + } + return searchReply(3); + } + return 'OK'; + }); + const store = new MemoryStore({ + client, + name: 'mem', + embedFn: fakeEmbed(8), + maxItemsPerScope: 2, + }); + + await store.remember('content', { namespace: 'u1' }); + + const del = client.call.mock.calls.find((c) => c[0] === 'DEL'); + expect(del).toEqual(['DEL', 'mem:mem:a']); + const hincr = client.call.mock.calls.find( + (c) => c[0] === 'HINCRBY' && c[1] === 'mem:__mem_stats', + ); + expect(hincr).toEqual(['HINCRBY', 'mem:__mem_stats', 'evictions', '1']); + }); + + it('queries capacity by the written item scope', async () => { + const client = mockClient((command) => (command === 'FT.SEARCH' ? searchReply(2) : 'OK')); + const store = new MemoryStore({ + client, + name: 'mem', + embedFn: fakeEmbed(8), + maxItemsPerScope: 2, + }); + + await store.remember('content', { namespace: 'u1' }); + + const search = client.call.mock.calls.find((c) => c[0] === 'FT.SEARCH'); + expect(search?.[1]).toBe('mem:mem:idx'); + expect(search?.[2]).toBe('(@namespace:{u1})'); + }); + + it('does not evict or fetch candidates when within capacity', async () => { + const client = mockClient((command) => (command === 'FT.SEARCH' ? searchReply(2) : 'OK')); + const store = new MemoryStore({ + client, + name: 'mem', + embedFn: fakeEmbed(8), + maxItemsPerScope: 2, + }); + + await store.remember('content', { namespace: 'u1' }); + + const searches = client.call.mock.calls.filter((c) => c[0] === 'FT.SEARCH'); + expect(searches).toHaveLength(1); + const commands = client.call.mock.calls.map((c) => c[0]); + expect(commands).not.toContain('DEL'); + expect(commands).not.toContain('HINCRBY'); + }); + + it('performs no capacity check when maxItemsPerScope is not configured', async () => { + const client = mockClient(() => 'OK'); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await store.remember('content', { namespace: 'u1' }); + + expect(client.call.mock.calls.some((c) => c[0] === 'FT.SEARCH')).toBe(false); + }); + + it('never lets a capacity-enforcement failure break the write', async () => { + const client = mockClient((command) => { + if (command === 'FT.SEARCH') { + throw new Error('search boom'); + } + return 'OK'; + }); + const store = new MemoryStore({ + client, + name: 'mem', + embedFn: fakeEmbed(8), + maxItemsPerScope: 2, + }); + + const id = await store.remember('content', { namespace: 'u1' }); + + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/agent-memory/src/__tests__/selectEvictions.test.ts b/packages/agent-memory/src/__tests__/selectEvictions.test.ts new file mode 100644 index 00000000..f1679432 --- /dev/null +++ b/packages/agent-memory/src/__tests__/selectEvictions.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { selectEvictions, type EvictionCandidate } from '../selectEvictions'; + +const HALF_LIFE = 604800; +const now = 1_000_000_000_000; +const weights = { similarity: 0.6, recency: 0.25, importance: 0.15 }; + +function candidate(key: string, importance: number, ageSeconds: number): EvictionCandidate { + return { key, importance, lastAccessedAt: now - ageSeconds * 1000 }; +} + +describe('selectEvictions', () => { + it('evicts nothing when the scope is at or under capacity', () => { + const items = [candidate('a', 0.1, 0), candidate('b', 0.9, 0)]; + expect(selectEvictions(items, 2, { now, halfLifeSeconds: HALF_LIFE, weights })).toEqual([]); + expect(selectEvictions(items, 5, { now, halfLifeSeconds: HALF_LIFE, weights })).toEqual([]); + }); + + it('drops exactly (count - max) items', () => { + const items = [ + candidate('a', 0.1, 0), + candidate('b', 0.2, 0), + candidate('c', 0.3, 0), + candidate('d', 0.4, 0), + ]; + const evicted = selectEvictions(items, 2, { now, halfLifeSeconds: HALF_LIFE, weights }); + expect(evicted).toHaveLength(2); + }); + + it('evicts the lowest-importance items when recency is equal', () => { + const items = [candidate('low', 0.1, 0), candidate('mid', 0.5, 0), candidate('high', 0.9, 0)]; + const evicted = selectEvictions(items, 1, { now, halfLifeSeconds: HALF_LIFE, weights }); + expect(evicted).toEqual(['low', 'mid']); + }); + + it('evicts the oldest items when importance is equal', () => { + const items = [ + candidate('fresh', 0.5, 0), + candidate('week', 0.5, HALF_LIFE), + candidate('ancient', 0.5, HALF_LIFE * 4), + ]; + const evicted = selectEvictions(items, 1, { now, halfLifeSeconds: HALF_LIFE, weights }); + expect(evicted).toEqual(['ancient', 'week']); + }); + + it('lets a fresh low-importance item outrank a stale high-importance one when recency dominates', () => { + const recencyHeavy = { similarity: 0, recency: 0.9, importance: 0.1 }; + const items = [ + candidate('staleImportant', 0.9, HALF_LIFE * 6), + candidate('freshTrivial', 0.1, 0), + ]; + const evicted = selectEvictions(items, 1, { + now, + halfLifeSeconds: HALF_LIFE, + weights: recencyHeavy, + }); + expect(evicted).toEqual(['staleImportant']); + }); + + it('returns every key when max is zero', () => { + const items = [candidate('a', 0.5, 0), candidate('b', 0.5, 10)]; + const evicted = selectEvictions(items, 0, { now, halfLifeSeconds: HALF_LIFE, weights }); + expect(evicted.sort()).toEqual(['a', 'b']); + }); +}); diff --git a/packages/agent-memory/src/compositeScore.ts b/packages/agent-memory/src/compositeScore.ts index c201265b..be44240d 100644 --- a/packages/agent-memory/src/compositeScore.ts +++ b/packages/agent-memory/src/compositeScore.ts @@ -12,12 +12,17 @@ export interface CompositeScoreParams { halfLifeSeconds: number; } +/** True half-life decay: 1 at age 0, 0.5 at one halfLifeSeconds, approaching 0 beyond. */ +export function recencyDecay(ageSeconds: number, halfLifeSeconds: number): number { + return Math.exp((-Math.LN2 * ageSeconds) / halfLifeSeconds); +} + /** * Weighted blend of semantic similarity, recency, and importance. * Recency is a true half-life decay: 0.5 at one halfLifeSeconds. */ export function compositeScore(params: CompositeScoreParams): number { - const recency = Math.exp((-Math.LN2 * params.ageSeconds) / params.halfLifeSeconds); + const recency = recencyDecay(params.ageSeconds, params.halfLifeSeconds); return ( params.weights.similarity * params.similarity + params.weights.recency * recency + diff --git a/packages/agent-memory/src/selectEvictions.ts b/packages/agent-memory/src/selectEvictions.ts new file mode 100644 index 00000000..e6b3c3eb --- /dev/null +++ b/packages/agent-memory/src/selectEvictions.ts @@ -0,0 +1,50 @@ +import { recencyDecay, type RecallWeights } from './compositeScore'; + +export interface EvictionCandidate { + key: string; + importance: number; + lastAccessedAt: number; +} + +export interface SelectEvictionsOptions { + now: number; + halfLifeSeconds: number; + weights: RecallWeights; +} + +function evictionScore(candidate: EvictionCandidate, options: SelectEvictionsOptions): number { + const { weights } = options; + const denom = weights.importance + weights.recency; + if (denom === 0) { + return 0; + } + const ageSeconds = (options.now - candidate.lastAccessedAt) / 1000; + const recency = recencyDecay(ageSeconds, options.halfLifeSeconds); + return (weights.importance * candidate.importance + weights.recency * recency) / denom; +} + +/** + * Pick the keys to evict so that `maxItems` remain. Eviction blends importance + * with last-access recency (the recall weights, minus similarity, renormalized); + * lowest-scoring keys go first, ties broken toward the older last-access. + */ +export function selectEvictions( + candidates: EvictionCandidate[], + maxItems: number, + options: SelectEvictionsOptions, +): string[] { + const dropCount = candidates.length - Math.max(0, maxItems); + if (dropCount <= 0) { + return []; + } + const ranked = candidates + .map((candidate) => ({ + key: candidate.key, + score: evictionScore(candidate, options), + lastAccessedAt: candidate.lastAccessedAt, + })) + .sort((a, b) => + a.score !== b.score ? a.score - b.score : a.lastAccessedAt - b.lastAccessedAt, + ); + return ranked.slice(0, dropCount).map((entry) => entry.key); +} diff --git a/packages/agent-memory/src/types.ts b/packages/agent-memory/src/types.ts index 2a697e4f..6577bd4a 100644 --- a/packages/agent-memory/src/types.ts +++ b/packages/agent-memory/src/types.ts @@ -16,6 +16,7 @@ export interface RememberOptions extends MemoryScope { importance?: number; tags?: string[]; source?: string; + ttl?: number; } export interface MemoryItem extends MemoryScope { From 49ef9d73bfcc134eb483af869814b451d2354441 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 18 Jun 2026 10:26:57 +0300 Subject: [PATCH 2/3] fix(agent-memory): scope capacity enforcement by tags enforceCapacity built its filter with empty tags, so a tag-only write collapsed to '*' and counted/evicted across the whole index instead of the tag partition used by recall and forgetByScope. Pass the write tags through so capacity is enforced on the same partition. --- packages/agent-memory/src/MemoryStore.ts | 8 +++++--- .../src/__tests__/MemoryStore.eviction.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/agent-memory/src/MemoryStore.ts b/packages/agent-memory/src/MemoryStore.ts index 45f8e612..6d7ebb71 100644 --- a/packages/agent-memory/src/MemoryStore.ts +++ b/packages/agent-memory/src/MemoryStore.ts @@ -217,14 +217,16 @@ export class MemoryStore { await this.client.call('EXEC'); } - private async enforceCapacity(scope: MemoryScope, now: number): Promise { + private async enforceCapacity(scope: MemoryScope & { tags?: string[] }, now: number): Promise { const max = this.maxItemsPerScope; if (max === undefined) { return; } // Count-first so the common in-capacity write pays only a cheap LIMIT 0 0 - // probe and never fetches candidate rows. - const filter = buildScopeFilter(scope, []); + // probe and never fetches candidate rows. Tags are part of the partition + // (as in recall/forgetByScope), so a tag-scoped write caps its own tag + // bucket instead of collapsing to `*` and evicting across the whole index. + const filter = buildScopeFilter(scope, scope.tags ?? []); const countRaw = await this.client.call( 'FT.SEARCH', `${this.name}:mem:idx`, diff --git a/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts b/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts index 45c2b67e..9dbe3a55 100644 --- a/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts +++ b/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts @@ -106,6 +106,22 @@ describe('MemoryStore capacity eviction', () => { expect(search?.[2]).toBe('(@namespace:{u1})'); }); + it('partitions capacity by tags so a tag-scoped write does not cap the whole index', async () => { + const client = mockClient((command) => (command === 'FT.SEARCH' ? searchReply(2) : 'OK')); + const store = new MemoryStore({ + client, + name: 'mem', + embedFn: fakeEmbed(8), + maxItemsPerScope: 2, + }); + + await store.remember('content', { tags: ['teamx'] }); + + const search = client.call.mock.calls.find((c) => c[0] === 'FT.SEARCH'); + expect(search?.[2]).toBe('(@tags:{teamx})'); + expect(search?.[2]).not.toBe('*'); + }); + it('does not evict or fetch candidates when within capacity', async () => { const client = mockClient((command) => (command === 'FT.SEARCH' ? searchReply(2) : 'OK')); const store = new MemoryStore({ From 7ee56dfc0b2c8e66a4f0a6e88d3b872aa403784c Mon Sep 17 00:00:00 2001 From: jamby77 Date: Fri, 19 Jun 2026 12:21:02 +0300 Subject: [PATCH 3/3] fix(agent-memory): scope capacity to real scopes and harden the ttl transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enforceCapacity skips a fully-unscoped write (filter '*') instead of counting/evicting across the whole index — maxItemsPerScope is per-scope - ttl writeRecord wraps MULTI/HSET/EXPIRE/EXEC in try/catch with DISCARD so a failure can't leave the connection mid-transaction; documents the single-connection atomicity assumption - note that capacity is enforced approximately (one write behind) under index lag --- packages/agent-memory/src/MemoryStore.ts | 32 ++++++++++++++----- .../__tests__/MemoryStore.eviction.test.ts | 27 ++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/agent-memory/src/MemoryStore.ts b/packages/agent-memory/src/MemoryStore.ts index 6d7ebb71..c14f0262 100644 --- a/packages/agent-memory/src/MemoryStore.ts +++ b/packages/agent-memory/src/MemoryStore.ts @@ -210,11 +210,19 @@ export class MemoryStore { return; } // Set the hash and its expiry in one transaction so a crash between the two - // can't leave a memory that should expire living forever. + // can't leave a memory that should expire living forever. Atomicity assumes + // the client routes these calls to a single connection (the MemoryStoreClient + // contract); on a pooled client that splits them the guarantee is lost. await this.client.call('MULTI'); - await this.client.call('HSET', key, ...fields); - await this.client.call('EXPIRE', key, String(ttl)); - await this.client.call('EXEC'); + try { + await this.client.call('HSET', key, ...fields); + await this.client.call('EXPIRE', key, String(ttl)); + await this.client.call('EXEC'); + } catch (err) { + // Clear the half-built transaction so the connection isn't left mid-MULTI. + await this.client.call('DISCARD').catch(() => undefined); + throw err; + } } private async enforceCapacity(scope: MemoryScope & { tags?: string[] }, now: number): Promise { @@ -222,11 +230,19 @@ export class MemoryStore { if (max === undefined) { return; } - // Count-first so the common in-capacity write pays only a cheap LIMIT 0 0 - // probe and never fetches candidate rows. Tags are part of the partition - // (as in recall/forgetByScope), so a tag-scoped write caps its own tag - // bucket instead of collapsing to `*` and evicting across the whole index. + // Tags are part of the partition (as in recall/forgetByScope), so a + // tag-scoped write caps its own tag bucket. const filter = buildScopeFilter(scope, scope.tags ?? []); + if (filter === '*') { + // A fully-unscoped write has no scope to bound: enforcing here would count + // and evict across the entire index (every other scope's memories), which + // `maxItemsPerScope` does not promise. Skip — the write stays, uncapped. + return; + } + // Count-first so the common in-capacity write pays only a cheap LIMIT 0 0 + // probe and never fetches candidate rows. Both the count and the candidate + // scan go through FT.SEARCH, so under HNSW index lag the cap is enforced + // approximately and up to one write behind (the unit tests mock this exact). const countRaw = await this.client.call( 'FT.SEARCH', `${this.name}:mem:idx`, diff --git a/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts b/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts index 9dbe3a55..af3eced0 100644 --- a/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts +++ b/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts @@ -56,6 +56,19 @@ describe('MemoryStore TTL writes', () => { expect(commands).toContain('HSET'); expect(commands).not.toContain('EXPIRE'); }); + + it('DISCARDs and propagates when a ttl write fails mid-transaction', async () => { + const client = mockClient((command) => { + if (command === 'EXPIRE') { + throw new Error('boom'); + } + return 'OK'; + }); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await expect(store.remember('x', { ttl: 60 })).rejects.toThrow(/boom/); + expect(client.call.mock.calls.some((c) => c[0] === 'DISCARD')).toBe(true); + }); }); describe('MemoryStore capacity eviction', () => { @@ -149,6 +162,20 @@ describe('MemoryStore capacity eviction', () => { expect(client.call.mock.calls.some((c) => c[0] === 'FT.SEARCH')).toBe(false); }); + it('skips capacity enforcement for a fully-unscoped write (no global eviction)', async () => { + const client = mockClient(() => 'OK'); + const store = new MemoryStore({ + client, + name: 'mem', + embedFn: fakeEmbed(8), + maxItemsPerScope: 1, + }); + + await store.remember('content'); + + expect(client.call.mock.calls.some((c) => c[0] === 'FT.SEARCH')).toBe(false); + }); + it('never lets a capacity-enforcement failure break the write', async () => { const client = mockClient((command) => { if (command === 'FT.SEARCH') {