diff --git a/packages/agent-memory/src/MemoryStore.ts b/packages/agent-memory/src/MemoryStore.ts index 7717ef43..ca1d77e5 100644 --- a/packages/agent-memory/src/MemoryStore.ts +++ b/packages/agent-memory/src/MemoryStore.ts @@ -33,6 +33,9 @@ const EVICTION_SCAN_LIMIT = 10000; const CONSOLIDATE_SCAN_LIMIT = 10000; const DEFAULT_SUMMARY_IMPORTANCE = 0.7; const SUMMARY_SOURCE = 'summary'; +const DEFAULT_CONFIG_REFRESH_MS = 30000; +const MIN_CONFIG_REFRESH_MS = 1000; +const MAX_DISTANCE = 2; // Read lazily so only discovery users pay the disk read on import (and avoid a // bundler hazard, since package.json is not always emitted). @@ -45,6 +48,18 @@ export interface MemoryDiscoveryConfig { heartbeatIntervalMs?: number; } +export interface MemoryConfigRefreshConfig { + enabled?: boolean; + intervalMs?: number; +} + +export interface MemoryConfigSnapshot { + threshold: number; + weights: RecallWeights; + halfLifeSeconds: number; + maxItemsPerScope?: number; +} + export interface MemoryStoreOptions { client: MemoryStoreClient; name: string; @@ -54,16 +69,23 @@ export interface MemoryStoreOptions { halfLifeSeconds?: number; maxItemsPerScope?: number; discovery?: boolean | MemoryDiscoveryConfig; + configRefresh?: boolean | MemoryConfigRefreshConfig; } export class MemoryStore { private readonly client: MemoryStoreClient; private readonly name: string; private readonly embedFn: EmbedFn; - private readonly defaultThreshold: number; - private readonly weights: RecallWeights; - private readonly halfLifeSeconds: number; - private readonly maxItemsPerScope?: number; + private defaultThreshold: number; + private weights: RecallWeights; + private halfLifeSeconds: number; + private maxItemsPerScope?: number; + private readonly initialThreshold: number; + private readonly initialWeights: RecallWeights; + private readonly initialHalfLifeSeconds: number; + private readonly initialMaxItemsPerScope?: number; + private readonly configKey: string; + private configRefreshHandle: ReturnType | null = null; private readonly discovery: MemoryDiscovery | null; private discoveryReady: Promise | null = null; private dims?: number; @@ -72,11 +94,119 @@ export class MemoryStore { this.client = options.client; this.name = options.name; this.embedFn = options.embedFn; - this.defaultThreshold = options.defaultThreshold ?? DEFAULT_THRESHOLD; - this.weights = options.weights ?? DEFAULT_WEIGHTS; - this.halfLifeSeconds = options.halfLifeSeconds ?? DEFAULT_HALF_LIFE_SECONDS; - this.maxItemsPerScope = options.maxItemsPerScope; + this.initialThreshold = options.defaultThreshold ?? DEFAULT_THRESHOLD; + this.initialWeights = { ...(options.weights ?? DEFAULT_WEIGHTS) }; + this.initialHalfLifeSeconds = options.halfLifeSeconds ?? DEFAULT_HALF_LIFE_SECONDS; + this.initialMaxItemsPerScope = options.maxItemsPerScope; + this.defaultThreshold = this.initialThreshold; + this.weights = { ...this.initialWeights }; + this.halfLifeSeconds = this.initialHalfLifeSeconds; + this.maxItemsPerScope = this.initialMaxItemsPerScope; + this.configKey = `${this.name}:__mem_config`; this.discovery = this.createDiscovery(options.discovery); + this.startConfigRefresh(options.configRefresh); + } + + currentConfig(): MemoryConfigSnapshot { + return { + threshold: this.defaultThreshold, + weights: { ...this.weights }, + halfLifeSeconds: this.halfLifeSeconds, + maxItemsPerScope: this.maxItemsPerScope, + }; + } + + async refreshConfig(): Promise { + try { + const raw = await this.client.call('HGETALL', this.configKey); + this.applyConfig(parseHashReply(raw)); + } catch { + // Best-effort: a failed refresh keeps the last-known config in place. + } + } + + private startConfigRefresh(config?: boolean | MemoryConfigRefreshConfig): void { + if (!config) { + return; + } + const settings = config === true ? {} : config; + if (settings.enabled === false) { + return; + } + const intervalMs = Math.max( + MIN_CONFIG_REFRESH_MS, + settings.intervalMs ?? DEFAULT_CONFIG_REFRESH_MS, + ); + void this.refreshConfig(); + const handle = setInterval(() => { + void this.refreshConfig(); + }, intervalMs); + handle.unref?.(); + this.configRefreshHandle = handle; + } + + private applyConfig(raw: Record): void { + let threshold = this.initialThreshold; + // Weights are a partial update: if any component is in the config, start + // from the LIVE weights and overlay only what's present, so tuning one knob + // (the proposal engine's common case) doesn't reset the others. With no + // weight field at all, fall back to the constructor values like the rest. + const weightFieldPresent = + raw['recall.weights.similarity'] !== undefined || + raw['recall.weights.recency'] !== undefined || + raw['recall.weights.importance'] !== undefined; + const weights: RecallWeights = { ...(weightFieldPresent ? this.weights : this.initialWeights) }; + let halfLifeSeconds = this.initialHalfLifeSeconds; + let maxItemsPerScope = this.initialMaxItemsPerScope; + + for (const [field, value] of Object.entries(raw)) { + const num = Number(value); + if (!Number.isFinite(num)) { + continue; + } + switch (field) { + case 'recall.threshold': + if (num >= 0 && num <= MAX_DISTANCE) { + threshold = num; + } + break; + case 'recall.weights.similarity': + if (num >= 0) { + weights.similarity = num; + } + break; + case 'recall.weights.recency': + if (num >= 0) { + weights.recency = num; + } + break; + case 'recall.weights.importance': + if (num >= 0) { + weights.importance = num; + } + break; + case 'recall.halfLifeSeconds': + if (num > 0) { + halfLifeSeconds = num; + } + break; + case 'maxItemsPerScope': + if (num >= 1) { + maxItemsPerScope = Math.floor(num); + } + break; + default: + break; + } + } + + this.defaultThreshold = threshold; + // An all-zero weight vector would make every composite score 0 and leave + // recall ordering undefined, so reject it and keep the configured weights. + const weightSum = weights.similarity + weights.recency + weights.importance; + this.weights = weightSum > 0 ? weights : { ...this.initialWeights }; + this.halfLifeSeconds = halfLifeSeconds; + this.maxItemsPerScope = maxItemsPerScope; } private createDiscovery(config?: boolean | MemoryDiscoveryConfig): MemoryDiscovery | null { @@ -102,6 +232,10 @@ export class MemoryStore { } async close(): Promise { + if (this.configRefreshHandle) { + clearInterval(this.configRefreshHandle); + this.configRefreshHandle = null; + } if (this.discoveryReady) { await this.discoveryReady.catch(() => undefined); } @@ -114,6 +248,9 @@ export class MemoryStore { const k = options.k ?? DEFAULT_RECALL_K; const threshold = options.threshold ?? this.defaultThreshold; const weights = options.weights ?? this.weights; + // Snapshot the half-life alongside threshold/weights so a concurrent + // configRefresh can't score one recall with a mix of config versions. + const halfLifeSeconds = this.halfLifeSeconds; const fetchK = k * RECALL_OVERFETCH; const tags = options.tags ?? []; const scope = { @@ -161,7 +298,7 @@ export class MemoryStore { ageSeconds, importance: item.importance, weights, - halfLifeSeconds: this.halfLifeSeconds, + halfLifeSeconds, }); if (!Number.isFinite(score)) { continue; @@ -386,6 +523,11 @@ export class MemoryStore { if (max === undefined) { return; } + // Snapshot the eviction tunables alongside max so an opt-in configRefresh + // landing mid-pass can't score victims with a different weight/half-life + // set than the capacity check ran with. + const weights = this.weights; + const halfLifeSeconds = this.halfLifeSeconds; // 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 ?? []); @@ -444,8 +586,8 @@ export class MemoryStore { const dropCount = Math.min(total - max, candidates.length); const evictKeys = selectEvictions(candidates, candidates.length - dropCount, { now, - halfLifeSeconds: this.halfLifeSeconds, - weights: this.weights, + halfLifeSeconds, + weights, }); if (evictKeys.length === 0) { return; @@ -480,3 +622,16 @@ function ftSearchTotal(raw: unknown): number { return Number.isFinite(total) && total > 0 ? total : 0; } +function parseHashReply(raw: unknown): Record { + const out: Record = {}; + if (Array.isArray(raw)) { + for (let i = 0; i + 1 < raw.length; i += 2) { + out[String(raw[i])] = String(raw[i + 1]); + } + } else if (raw !== null && typeof raw === 'object') { + for (const [field, value] of Object.entries(raw as Record)) { + out[field] = String(value); + } + } + return out; +} diff --git a/packages/agent-memory/src/__tests__/MemoryStore.config.test.ts b/packages/agent-memory/src/__tests__/MemoryStore.config.test.ts new file mode 100644 index 00000000..f3ac97dd --- /dev/null +++ b/packages/agent-memory/src/__tests__/MemoryStore.config.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi } from 'vitest'; +import { MemoryStore } from '../MemoryStore'; +import { fakeEmbed } from './helpers/fakeEmbed'; +import { mockClient } from './helpers/mockClient'; + +const DEFAULT_WEIGHTS = { similarity: 0.6, recency: 0.25, importance: 0.15 }; + +function configReply(fields: Record): string[] { + const flat: string[] = []; + for (const [field, value] of Object.entries(fields)) { + flat.push(field, value); + } + return flat; +} + +function recallHit(distance: number): unknown[] { + const now = Date.now(); + const fields: Record = { + __score: String(distance), + content: 'c', + importance: '0.5', + created_at: String(now), + last_accessed_at: String(now), + access_count: '0', + }; + const flat: string[] = []; + for (const [field, value] of Object.entries(fields)) { + flat.push(field, value); + } + return ['1', 'mem:mem:a', flat]; +} + +function configClient(fields: Record, others?: (command: string) => unknown) { + return mockClient((command, ...args) => { + if (command === 'HGETALL') { + return configReply(fields); + } + return others ? others(command) : 'OK'; + }); +} + +describe('MemoryStore.currentConfig', () => { + it('reflects the constructor defaults before any refresh', () => { + const store = new MemoryStore({ client: mockClient(), name: 'mem', embedFn: fakeEmbed(8) }); + expect(store.currentConfig()).toEqual({ + threshold: 0.25, + weights: DEFAULT_WEIGHTS, + halfLifeSeconds: 604800, + maxItemsPerScope: undefined, + }); + }); +}); + +describe('MemoryStore config refresh', () => { + it('applies recall.threshold from the config hash', async () => { + const store = new MemoryStore({ + client: configClient({ 'recall.threshold': '0.5' }), + name: 'mem', + embedFn: fakeEmbed(8), + }); + await store.refreshConfig(); + expect(store.currentConfig().threshold).toBe(0.5); + }); + + it('applies recall weights from the config hash', async () => { + const store = new MemoryStore({ + client: configClient({ + 'recall.weights.similarity': '0.2', + 'recall.weights.recency': '0.7', + 'recall.weights.importance': '0.1', + }), + name: 'mem', + embedFn: fakeEmbed(8), + }); + await store.refreshConfig(); + expect(store.currentConfig().weights).toEqual({ + similarity: 0.2, + recency: 0.7, + importance: 0.1, + }); + }); + + it('applies recall.halfLifeSeconds and maxItemsPerScope', async () => { + const store = new MemoryStore({ + client: configClient({ 'recall.halfLifeSeconds': '3600', maxItemsPerScope: '100' }), + name: 'mem', + embedFn: fakeEmbed(8), + }); + await store.refreshConfig(); + expect(store.currentConfig().halfLifeSeconds).toBe(3600); + expect(store.currentConfig().maxItemsPerScope).toBe(100); + }); + + it('leaves unspecified tunables at their constructor values', async () => { + const store = new MemoryStore({ + client: configClient({ 'recall.threshold': '0.5' }), + name: 'mem', + embedFn: fakeEmbed(8), + weights: { similarity: 0.5, recency: 0.3, importance: 0.2 }, + }); + await store.refreshConfig(); + expect(store.currentConfig().threshold).toBe(0.5); + expect(store.currentConfig().weights).toEqual({ + similarity: 0.5, + recency: 0.3, + importance: 0.2, + }); + }); + + it('reverts a tunable to its constructor value when the field disappears', async () => { + let present = true; + const client = mockClient((command) => { + if (command === 'HGETALL') { + return present ? configReply({ 'recall.threshold': '0.9' }) : configReply({}); + } + return 'OK'; + }); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await store.refreshConfig(); + expect(store.currentConfig().threshold).toBe(0.9); + + present = false; + await store.refreshConfig(); + expect(store.currentConfig().threshold).toBe(0.25); + }); + + it('ignores invalid values and keeps the constructor defaults', async () => { + const store = new MemoryStore({ + client: configClient({ + 'recall.threshold': '5', + 'recall.weights.recency': 'not-a-number', + 'recall.halfLifeSeconds': '-1', + maxItemsPerScope: '0', + }), + name: 'mem', + embedFn: fakeEmbed(8), + }); + await store.refreshConfig(); + expect(store.currentConfig()).toEqual({ + threshold: 0.25, + weights: DEFAULT_WEIGHTS, + halfLifeSeconds: 604800, + maxItemsPerScope: undefined, + }); + }); + + it('rejects an all-zero weight vector and keeps the constructor weights', async () => { + const store = new MemoryStore({ + client: configClient({ + 'recall.weights.similarity': '0', + 'recall.weights.recency': '0', + 'recall.weights.importance': '0', + }), + name: 'mem', + embedFn: fakeEmbed(8), + }); + await store.refreshConfig(); + expect(store.currentConfig().weights).toEqual(DEFAULT_WEIGHTS); + }); + + it('preserves the live weight components when only a subset is written', async () => { + let hash: Record = { + 'recall.weights.similarity': '0.2', + 'recall.weights.recency': '0.7', + 'recall.weights.importance': '0.1', + }; + const client = mockClient((command) => (command === 'HGETALL' ? configReply(hash) : 'OK')); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await store.refreshConfig(); + expect(store.currentConfig().weights).toEqual({ similarity: 0.2, recency: 0.7, importance: 0.1 }); + + // A partial write that nudges only similarity must keep the live recency + // and importance, not reset them to the constructor defaults. + hash = { 'recall.weights.similarity': '0.5' }; + await store.refreshConfig(); + expect(store.currentConfig().weights).toEqual({ similarity: 0.5, recency: 0.7, importance: 0.1 }); + }); + + it('live-applies a looser threshold to recall', async () => { + const client = mockClient((command) => { + if (command === 'HGETALL') { + return configReply({ 'recall.threshold': '0.5' }); + } + if (command === 'FT.SEARCH') { + return recallHit(0.4); + } + return 'OK'; + }); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + expect(await store.recall('q', { k: 1 })).toHaveLength(0); + await store.refreshConfig(); + expect(await store.recall('q', { k: 1 })).toHaveLength(1); + }); + + it('does not poll the config hash when refresh is not enabled', async () => { + const client = mockClient(() => 'OK'); + new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + await Promise.resolve(); + expect(client.call.mock.calls.some((c) => c[0] === 'HGETALL')).toBe(false); + }); + + it('reads immediately and on the interval when enabled, and stops on close', async () => { + vi.useFakeTimers(); + try { + const client = configClient({ 'recall.threshold': '0.4' }); + const store = new MemoryStore({ + client, + name: 'mem', + embedFn: fakeEmbed(8), + configRefresh: { intervalMs: 1000 }, + }); + await vi.advanceTimersByTimeAsync(0); + const initial = client.call.mock.calls.filter((c) => c[0] === 'HGETALL').length; + expect(initial).toBeGreaterThanOrEqual(1); + + await vi.advanceTimersByTimeAsync(1000); + const afterTick = client.call.mock.calls.filter((c) => c[0] === 'HGETALL').length; + expect(afterTick).toBeGreaterThan(initial); + + await store.close(); + await vi.advanceTimersByTimeAsync(3000); + const afterClose = client.call.mock.calls.filter((c) => c[0] === 'HGETALL').length; + expect(afterClose).toBe(afterTick); + } finally { + vi.useRealTimers(); + } + }); + + it('never throws when the config read fails (best-effort)', async () => { + const client = mockClient((command) => { + if (command === 'HGETALL') { + throw new Error('hgetall boom'); + } + return 'OK'; + }); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await expect(store.refreshConfig()).resolves.toBeUndefined(); + expect(store.currentConfig().threshold).toBe(0.25); + }); +}); diff --git a/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts b/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts index af3eced0..859374d8 100644 --- a/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts +++ b/packages/agent-memory/src/__tests__/MemoryStore.eviction.test.ts @@ -195,4 +195,45 @@ describe('MemoryStore capacity eviction', () => { expect(typeof id).toBe('string'); expect(id.length).toBeGreaterThan(0); }); + + it('snapshots eviction weights so a mid-pass config refresh cannot change the victim', async () => { + let store: MemoryStore; + const client = mockClient((command, ...args) => { + if (command === 'HGETALL') { + return [ + 'recall.weights.similarity', + '0', + 'recall.weights.recency', + '0.9', + 'recall.weights.importance', + '0.1', + ]; + } + if (command === 'FT.SEARCH') { + if (args.includes('RETURN')) { + return store.refreshConfig().then(() => + searchReply(2, [ + ['mem:mem:stale', fields(0.9, 1000)], + ['mem:mem:recent', fields(0.1, Date.now())], + ]), + ); + } + return searchReply(2); + } + return 'OK'; + }); + store = new MemoryStore({ + client, + name: 'mem', + embedFn: fakeEmbed(8), + maxItemsPerScope: 1, + halfLifeSeconds: 100, + weights: { similarity: 0, recency: 0.1, importance: 0.9 }, + }); + + await store.remember('content', { namespace: 'u1' }); + + const del = client.call.mock.calls.find((c) => c[0] === 'DEL'); + expect(del).toEqual(['DEL', 'mem:mem:recent']); + }); }); diff --git a/packages/agent-memory/src/index.ts b/packages/agent-memory/src/index.ts index a12bb20c..634510d7 100644 --- a/packages/agent-memory/src/index.ts +++ b/packages/agent-memory/src/index.ts @@ -1,6 +1,11 @@ export * from '@betterdb/agent-cache'; export { MemoryStore } from './MemoryStore'; -export type { MemoryStoreOptions, MemoryDiscoveryConfig } from './MemoryStore'; +export type { + MemoryStoreOptions, + MemoryDiscoveryConfig, + MemoryConfigRefreshConfig, + MemoryConfigSnapshot, +} from './MemoryStore'; export { MemoryDiscovery, MEMORY_CACHE_TYPE, MEMORY_CAPABILITIES } from './discovery'; export type { MemoryDiscoveryDeps, MemoryMarker } from './discovery'; export { AgentMemory } from './AgentMemory';