Skip to content

Commit 925495a

Browse files
author
AllysonAI
committed
fix(memory-core): skip forced sync for healthy zero-hit search
1 parent adcba85 commit 925495a

3 files changed

Lines changed: 158 additions & 11 deletions

File tree

extensions/memory-core/src/memory-tool-manager.test-mocks.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type MemoryBackend = "builtin" | "qmd";
2424
let backend: MemoryBackend = "builtin";
2525
let workspaceDir = "/workspace";
2626
let customStatus: Record<string, unknown> | undefined;
27+
let statusOverrides: Record<string, unknown> | undefined;
2728
let searchImpl: SearchImpl = async () => [];
2829
let getManagerImpl:
2930
| ((params: { cfg?: unknown; agentId?: string; purpose?: string }) => Promise<{
@@ -54,6 +55,7 @@ const stubManager = {
5455
sources: ["memory" as const],
5556
sourceCounts: [{ source: "memory" as const, files: 1, chunks: 1 }],
5657
custom: customStatus,
58+
...statusOverrides,
5759
}),
5860
sync: vi.fn(),
5961
probeVectorAvailability: vi.fn(async () => true),
@@ -93,6 +95,10 @@ export function setMemoryCustomStatus(next: Record<string, unknown> | undefined)
9395
customStatus = next;
9496
}
9597

98+
export function setMemoryStatusOverrides(next: Record<string, unknown> | undefined): void {
99+
statusOverrides = next;
100+
}
101+
96102
export function setMemorySearchImpl(next: SearchImpl): void {
97103
searchImpl = next;
98104
}
@@ -120,6 +126,7 @@ export function resetMemoryToolMockState(overrides?: {
120126
backend = overrides?.backend ?? "builtin";
121127
workspaceDir = "/workspace";
122128
customStatus = undefined;
129+
statusOverrides = undefined;
123130
getManagerImpl = undefined;
124131
searchImpl = overrides?.searchImpl ?? (async () => []);
125132
readFileImpl =

extensions/memory-core/src/tools.test.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
setMemoryCustomStatus,
1212
setMemorySearchImpl,
1313
setMemorySearchManagerImpl,
14+
setMemoryStatusOverrides,
1415
} from "./memory-tool-manager.test-mocks.js";
1516
import { createMemorySearchTool, testing as memoryToolsTesting } from "./tools.js";
1617
import { MemoryGetSchema, MemorySearchSchema } from "./tools.shared.js";
@@ -58,6 +59,67 @@ describe("memory tool schemas", () => {
5859
});
5960
});
6061

62+
describe("memory_search zero-hit sync decisions", () => {
63+
it("only forces sync for unavailable or unready index status", () => {
64+
const decide = memoryToolsTesting.shouldForceSyncAfterZeroResults;
65+
const healthy = {
66+
backend: "qmd",
67+
dirty: false,
68+
files: 27,
69+
chunks: 472,
70+
vector: { available: true },
71+
fts: { available: true },
72+
custom: { indexIdentity: { status: "valid" } },
73+
};
74+
75+
expect(decide(healthy)).toEqual({
76+
shouldSync: false,
77+
reason: "healthy_zero_hit",
78+
});
79+
expect(decide({ ...healthy, dirty: true })).toEqual({
80+
shouldSync: false,
81+
reason: "healthy_zero_hit",
82+
});
83+
expect(decide({ ...healthy, backend: "builtin", dirty: true })).toEqual({
84+
shouldSync: true,
85+
reason: "dirty_index",
86+
});
87+
expect(decide({ ...healthy, backend: undefined, dirty: true })).toEqual({
88+
shouldSync: true,
89+
reason: "dirty_index",
90+
});
91+
expect(decide({ ...healthy, vector: { enabled: false, available: false } })).toEqual({
92+
shouldSync: false,
93+
reason: "healthy_zero_hit",
94+
});
95+
expect(decide({ ...healthy, fts: { enabled: false, available: false } })).toEqual({
96+
shouldSync: false,
97+
reason: "healthy_zero_hit",
98+
});
99+
expect(decide({ ...healthy, custom: { indexIdentity: { status: "missing" } } })).toEqual({
100+
shouldSync: true,
101+
reason: "index_identity_invalid",
102+
});
103+
expect(decide({ ...healthy, chunks: 0 })).toEqual({
104+
shouldSync: true,
105+
reason: "no_indexed_chunks",
106+
});
107+
expect(decide({ ...healthy, files: 0 })).toEqual({
108+
shouldSync: true,
109+
reason: "no_indexed_files",
110+
});
111+
expect(decide({ ...healthy, vector: { available: false } })).toEqual({
112+
shouldSync: true,
113+
reason: "vector_unavailable",
114+
});
115+
expect(decide({ ...healthy, fts: { available: false } })).toEqual({
116+
shouldSync: true,
117+
reason: "fts_unavailable",
118+
});
119+
expect(decide(null)).toEqual({ shouldSync: true, reason: "status_unavailable" });
120+
});
121+
});
122+
61123
describe("memory_search unavailable payloads", () => {
62124
beforeEach(() => {
63125
resetMemoryToolMockState({ searchImpl: async () => [] });
@@ -313,8 +375,42 @@ describe("memory_search unavailable payloads", () => {
313375
expect(getMemoryCloseMockCalls()).toBe(1);
314376
});
315377

316-
it("forces a sync and retries once when the first search has zero hits", async () => {
378+
it("returns a healthy zero-hit without forcing sync when the index is ready", async () => {
379+
let searchCalls = 0;
380+
setMemorySearchImpl(async () => {
381+
searchCalls += 1;
382+
if (searchCalls === 1) {
383+
return [];
384+
}
385+
return [
386+
{
387+
path: "MEMORY.md",
388+
startLine: 1,
389+
endLine: 1,
390+
score: 0.9,
391+
snippet: "Thread-hidden codename: ORBIT-22.",
392+
source: "memory" as const,
393+
},
394+
];
395+
});
396+
397+
const tool = createMemorySearchToolOrThrow({
398+
config: {
399+
agents: { list: [{ id: "main", default: true }] },
400+
memory: { citations: "off" },
401+
},
402+
});
403+
const result = await tool.execute("zero-hit-retry", { query: "hidden thread codename" });
404+
const details = result.details as { results?: Array<{ path: string }> };
405+
406+
expect(details.results).toEqual([]);
407+
expect(searchCalls).toBe(1);
408+
expect(getMemorySyncMockCalls()).toBe(0);
409+
});
410+
411+
it("preserves the dirty zero-hit retry for non-qmd backends", async () => {
317412
let searchCalls = 0;
413+
setMemoryStatusOverrides({ dirty: true });
318414
setMemorySearchImpl(async () => {
319415
searchCalls += 1;
320416
if (searchCalls === 1) {
@@ -339,11 +435,11 @@ describe("memory_search unavailable payloads", () => {
339435
},
340436
});
341437
const result = await tool.execute("zero-hit-retry", { query: "hidden thread codename" });
438+
const details = result.details as { results?: Array<{ path: string }> };
342439

343-
expect((result.details as { results?: Array<{ path: string }> }).results?.[0]?.path).toBe(
344-
"MEMORY.md",
345-
);
440+
expect(details.results).toEqual([expect.objectContaining({ path: "MEMORY.md" })]);
346441
expect(searchCalls).toBe(2);
442+
expect(getMemorySyncMockCalls()).toBe(1);
347443
});
348444

349445
it("returns unavailable metadata when the index identity is paused", async () => {

extensions/memory-core/src/tools.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ type MemorySearchToolResult =
4444
| MemoryCorpusSearchResult;
4545
type MemoryManagerContext = Awaited<ReturnType<typeof getMemoryManagerContextWithPurpose>>;
4646
type ActiveMemoryManagerContext = Extract<MemoryManagerContext, { manager: unknown }>;
47+
type MemorySearchZeroResultStatus = {
48+
backend?: unknown;
49+
dirty?: unknown;
50+
files?: unknown;
51+
chunks?: unknown;
52+
vector?: unknown;
53+
fts?: unknown;
54+
custom?: unknown;
55+
};
4756

4857
const MEMORY_SEARCH_TOOL_TIMEOUT_MS = 15_000;
4958
const MEMORY_SEARCH_TOOL_COOLDOWN_MS = 60_000;
@@ -76,10 +85,42 @@ function recordMemorySearchToolCooldown(key: string, error: string): void {
7685
});
7786
}
7887

88+
function shouldForceSyncAfterZeroResults(status: MemorySearchZeroResultStatus | null | undefined): {
89+
shouldSync: boolean;
90+
reason: string;
91+
} {
92+
if (!status || typeof status !== "object") {
93+
return { shouldSync: true, reason: "status_unavailable" };
94+
}
95+
const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity);
96+
if (indexIdentity?.status && indexIdentity.status !== "valid") {
97+
return { shouldSync: true, reason: "index_identity_invalid" };
98+
}
99+
if (typeof status.chunks === "number" && status.chunks <= 0) {
100+
return { shouldSync: true, reason: "no_indexed_chunks" };
101+
}
102+
if (typeof status.files === "number" && status.files <= 0) {
103+
return { shouldSync: true, reason: "no_indexed_files" };
104+
}
105+
const vectorStatus = asRecord(status.vector);
106+
if (vectorStatus?.available === false && vectorStatus.enabled !== false) {
107+
return { shouldSync: true, reason: "vector_unavailable" };
108+
}
109+
const ftsStatus = asRecord(status.fts);
110+
if (ftsStatus?.available === false && ftsStatus.enabled !== false) {
111+
return { shouldSync: true, reason: "fts_unavailable" };
112+
}
113+
if (status.dirty === true && status.backend !== "qmd") {
114+
return { shouldSync: true, reason: "dirty_index" };
115+
}
116+
return { shouldSync: false, reason: "healthy_zero_hit" };
117+
}
118+
79119
export const testing = {
80120
resetMemorySearchToolCooldowns() {
81121
memorySearchToolCooldowns.clear();
82122
},
123+
shouldForceSyncAfterZeroResults,
83124
} as const;
84125

85126
function isActiveMemoryManagerContext(
@@ -532,13 +573,16 @@ export function createMemorySearchTool(options: {
532573
return;
533574
}
534575
if (rawResults.length === 0 && activeMemory.manager.sync) {
535-
await activeMemory.manager.sync({ reason: "search", force: true });
536-
rawResults = await activeMemory.manager.search(query, searchOptions);
537-
pausedIndexIdentityReason = resolvePausedMemoryIndexIdentityReason(
538-
activeMemory.manager.status(),
539-
);
540-
if (pausedIndexIdentityReason) {
541-
return;
576+
const syncDecision = shouldForceSyncAfterZeroResults(statusBeforeRetry);
577+
if (syncDecision.shouldSync) {
578+
await activeMemory.manager.sync({ reason: "search", force: true });
579+
rawResults = await activeMemory.manager.search(query, searchOptions);
580+
pausedIndexIdentityReason = resolvePausedMemoryIndexIdentityReason(
581+
activeMemory.manager.status(),
582+
);
583+
if (pausedIndexIdentityReason) {
584+
return;
585+
}
542586
}
543587
}
544588
rawResults = await filterMemorySearchHitsBySessionVisibility({

0 commit comments

Comments
 (0)