Skip to content

Commit 6864f6b

Browse files
committed
fix(agent-memory): push consolidate predicates into the query and skip summaries
- olderThanSeconds/maxImportance are now NUMERIC range clauses in the FT.SEARCH filter, so the scan limit applies to actual matches instead of an arbitrary first window, and content isn't transferred for discarded rows - the candidate scan excludes @source:{summary}, so a default run no longer re-folds prior summaries into a new summary (target them explicitly to do so) - extract buildConsolidateFilter (shares scope-clause logic with buildScopeFilter)
1 parent fb257a3 commit 6864f6b

4 files changed

Lines changed: 103 additions & 43 deletions

File tree

packages/agent-memory/src/MemoryStore.ts

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { randomUUID } from 'node:crypto';
22
import { encodeFloat32, parseFtSearchResponse } from '@betterdb/valkey-search-kit';
33
import { buildMemoryRecord } from './buildMemoryRecord';
4-
import { buildRecallQuery, buildScopeFilter, SCORE_FIELD } from './buildRecallQuery';
4+
import {
5+
buildConsolidateFilter,
6+
buildRecallQuery,
7+
buildScopeFilter,
8+
SCORE_FIELD,
9+
} from './buildRecallQuery';
510
import { parseMemoryItem } from './parseMemoryItem';
611
import { compositeScore, similarityFromDistance, type RecallWeights } from './compositeScore';
712
import { selectEvictions, type EvictionCandidate } from './selectEvictions';
@@ -10,7 +15,6 @@ import type {
1015
ConsolidateResult,
1116
EmbedFn,
1217
MemoryHit,
13-
MemoryItem,
1418
MemoryScope,
1519
MemoryStoreClient,
1620
RecallOptions,
@@ -27,6 +31,7 @@ const FORGET_MAX_BATCHES = 10000;
2731
const EVICTION_SCAN_LIMIT = 10000;
2832
const CONSOLIDATE_SCAN_LIMIT = 10000;
2933
const DEFAULT_SUMMARY_IMPORTANCE = 0.7;
34+
const SUMMARY_SOURCE = 'summary';
3035

3136
export interface MemoryStoreOptions {
3237
client: MemoryStoreClient;
@@ -231,10 +236,23 @@ export class MemoryStore {
231236
);
232237
}
233238

239+
// Push olderThanSeconds/maxImportance into the query (both are NUMERIC
240+
// indexed) so the scan limit applies to actual matches, not an arbitrary
241+
// first window, and we don't transfer rows we'd only discard. Prior
242+
// summaries are excluded so a default run doesn't re-fold them into a new
243+
// summary; target them explicitly to re-consolidate.
244+
const filter = buildConsolidateFilter(scope, tags, {
245+
maxCreatedAt:
246+
options.olderThanSeconds !== undefined
247+
? now - options.olderThanSeconds * 1000
248+
: undefined,
249+
maxImportance: options.maxImportance,
250+
excludeSource: SUMMARY_SOURCE,
251+
});
234252
const raw = await this.client.call(
235253
'FT.SEARCH',
236254
`${this.name}:mem:idx`,
237-
buildScopeFilter(scope, tags),
255+
filter,
238256
'RETURN',
239257
'10',
240258
'content',
@@ -253,9 +271,7 @@ export class MemoryStore {
253271
'DIALECT',
254272
'2',
255273
);
256-
const candidates = parseFtSearchResponse(raw)
257-
.map((hit) => parseMemoryItem(this.name, hit))
258-
.filter((item) => isConsolidationCandidate(item, options, now));
274+
const candidates = parseFtSearchResponse(raw).map((hit) => parseMemoryItem(this.name, hit));
259275

260276
if (candidates.length === 0) {
261277
return { consolidated: 0, created: [], deleted: 0 };
@@ -267,7 +283,7 @@ export class MemoryStore {
267283
const summaryId = await this.remember(summary, {
268284
...scope,
269285
tags,
270-
source: 'summary',
286+
source: SUMMARY_SOURCE,
271287
importance: options.summaryImportance ?? DEFAULT_SUMMARY_IMPORTANCE,
272288
});
273289

@@ -400,19 +416,3 @@ function ftSearchTotal(raw: unknown): number {
400416
return Number.isFinite(total) && total > 0 ? total : 0;
401417
}
402418

403-
function isConsolidationCandidate(
404-
item: MemoryItem,
405-
options: ConsolidateOptions,
406-
now: number,
407-
): boolean {
408-
if (options.maxImportance !== undefined && item.importance > options.maxImportance) {
409-
return false;
410-
}
411-
if (options.olderThanSeconds !== undefined) {
412-
const ageSeconds = (now - item.createdAt) / 1000;
413-
if (ageSeconds < options.olderThanSeconds) {
414-
return false;
415-
}
416-
}
417-
return true;
418-
}

packages/agent-memory/src/__tests__/MemoryStore.consolidate.test.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -89,34 +89,40 @@ describe('MemoryStore.consolidate', () => {
8989
expect(del?.slice(1).sort()).toEqual(['mem:mem:a', 'mem:mem:b']);
9090
});
9191

92-
it('selects only candidates older than olderThanSeconds', async () => {
92+
it('pushes olderThanSeconds into the query as a created_at upper bound', async () => {
9393
const summarize = vi.fn(async () => 'summary');
94-
const client = consolidatingClient([
95-
itemHit('old', { importance: 0.2, ageSeconds: 100000 }),
96-
itemHit('recent', { importance: 0.2, ageSeconds: 10 }),
97-
]);
94+
const client = consolidatingClient([itemHit('a', { importance: 0.2, ageSeconds: 100000 })]);
9895
const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) });
9996

100-
const result = await store.consolidate({ olderThanSeconds: 3600, summarize });
97+
await store.consolidate({ olderThanSeconds: 3600, summarize });
10198

102-
expect(summarize.mock.calls[0][0].map((i: { id: string }) => i.id)).toEqual(['old']);
103-
expect(result.consolidated).toBe(1);
104-
const del = client.call.mock.calls.find((c) => c[0] === 'DEL');
105-
expect(del?.slice(1)).toEqual(['mem:mem:old']);
99+
const search = client.call.mock.calls.find((c) => c[0] === 'FT.SEARCH');
100+
const filter = search?.[2] as string;
101+
// Server-side range so the scan limit applies to actual matches, not an
102+
// arbitrary first window.
103+
expect(filter).toMatch(/@created_at:\[-inf \d+\]/);
106104
});
107105

108-
it('selects only candidates at or below maxImportance', async () => {
106+
it('pushes maxImportance into the query as an importance upper bound', async () => {
109107
const summarize = vi.fn(async () => 'summary');
110-
const client = consolidatingClient([
111-
itemHit('low', { importance: 0.2, ageSeconds: 100000 }),
112-
itemHit('high', { importance: 0.9, ageSeconds: 100000 }),
113-
]);
108+
const client = consolidatingClient([itemHit('a', { importance: 0.2, ageSeconds: 100000 })]);
109+
const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) });
110+
111+
await store.consolidate({ maxImportance: 0.5, summarize });
112+
113+
const search = client.call.mock.calls.find((c) => c[0] === 'FT.SEARCH');
114+
expect(search?.[2] as string).toContain('@importance:[-inf 0.5]');
115+
});
116+
117+
it('excludes prior summaries from the candidate scan so a default run does not re-fold them', async () => {
118+
const summarize = vi.fn(async () => 'summary');
119+
const client = consolidatingClient([itemHit('a', { importance: 0.2, ageSeconds: 100000 })]);
114120
const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) });
115121

116-
const result = await store.consolidate({ maxImportance: 0.5, summarize });
122+
await store.consolidate({ namespace: 'u1', summarize });
117123

118-
expect(summarize.mock.calls[0][0].map((i: { id: string }) => i.id)).toEqual(['low']);
119-
expect(result.consolidated).toBe(1);
124+
const search = client.call.mock.calls.find((c) => c[0] === 'FT.SEARCH');
125+
expect(search?.[2] as string).toContain('-@source:{summary}');
120126
});
121127

122128
it('writes the summary scoped to the request at summaryImportance', async () => {

packages/agent-memory/src/__tests__/buildRecallQuery.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { buildRecallQuery } from '../buildRecallQuery';
2+
import { buildRecallQuery, buildConsolidateFilter } from '../buildRecallQuery';
33

44
describe('buildRecallQuery', () => {
55
it('builds a bare KNN query when there are no filters', () => {
@@ -18,3 +18,25 @@ describe('buildRecallQuery', () => {
1818
);
1919
});
2020
});
21+
22+
describe('buildConsolidateFilter', () => {
23+
it('appends inclusive NUMERIC ranges and a source exclusion to the scope filter', () => {
24+
expect(
25+
buildConsolidateFilter({ namespace: 'u1' }, ['pref'], {
26+
maxCreatedAt: 1000,
27+
maxImportance: 0.5,
28+
excludeSource: 'summary',
29+
}),
30+
).toBe('(@namespace:{u1} @tags:{pref} @created_at:[-inf 1000] @importance:[-inf 0.5] -@source:{summary})');
31+
});
32+
33+
it('omits absent predicates', () => {
34+
expect(buildConsolidateFilter({ threadId: 't' }, [], { excludeSource: 'summary' })).toBe(
35+
'(@threadId:{t} -@source:{summary})',
36+
);
37+
});
38+
39+
it('still constrains by range when there is no scope', () => {
40+
expect(buildConsolidateFilter({}, [], { maxImportance: 0.3 })).toBe('(@importance:[-inf 0.3])');
41+
});
42+
});

packages/agent-memory/src/buildRecallQuery.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { MemoryScope } from './types';
44
export const SCORE_FIELD = '__score';
55
export const VECTOR_FIELD = 'vector';
66

7-
export function buildScopeFilter(scope: MemoryScope, tags: string[]): string {
7+
function scopeClauses(scope: MemoryScope, tags: string[]): string[] {
88
const clauses: string[] = [];
99
if (scope.threadId !== undefined) {
1010
clauses.push(`@threadId:{${escapeTag(scope.threadId)}}`);
@@ -18,12 +18,44 @@ export function buildScopeFilter(scope: MemoryScope, tags: string[]): string {
1818
for (const tag of tags) {
1919
clauses.push(`@tags:{${escapeTag(tag)}}`);
2020
}
21+
return clauses;
22+
}
23+
24+
function joinClauses(clauses: string[]): string {
2125
if (clauses.length === 0) {
2226
return '*';
2327
}
2428
return `(${clauses.join(' ')})`;
2529
}
2630

31+
export function buildScopeFilter(scope: MemoryScope, tags: string[]): string {
32+
return joinClauses(scopeClauses(scope, tags));
33+
}
34+
35+
export interface ConsolidateFilterOptions {
36+
maxCreatedAt?: number;
37+
maxImportance?: number;
38+
excludeSource?: string;
39+
}
40+
41+
export function buildConsolidateFilter(
42+
scope: MemoryScope,
43+
tags: string[],
44+
options: ConsolidateFilterOptions,
45+
): string {
46+
const clauses = scopeClauses(scope, tags);
47+
if (options.maxCreatedAt !== undefined) {
48+
clauses.push(`@created_at:[-inf ${options.maxCreatedAt}]`);
49+
}
50+
if (options.maxImportance !== undefined) {
51+
clauses.push(`@importance:[-inf ${options.maxImportance}]`);
52+
}
53+
if (options.excludeSource !== undefined) {
54+
clauses.push(`-@source:{${escapeTag(options.excludeSource)}}`);
55+
}
56+
return joinClauses(clauses);
57+
}
58+
2759
export function buildRecallQuery(k: number, scope: MemoryScope, tags: string[]): string {
2860
return `${buildScopeFilter(scope, tags)}=>[KNN ${k} @${VECTOR_FIELD} $vec AS ${SCORE_FIELD}]`;
2961
}

0 commit comments

Comments
 (0)