Skip to content

Commit e437bd1

Browse files
authored
Merge f932d64 into 1d77170
2 parents 1d77170 + f932d64 commit e437bd1

24 files changed

Lines changed: 749 additions & 33 deletions

extensions/active-memory/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const hoisted = vi.hoisted(() => {
2727
},
2828
};
2929
return {
30+
closeActiveMemorySearchManager: vi.fn(async () => {}),
3031
sessionStore,
3132
updateSessionStore: vi.fn(
3233
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
@@ -36,6 +37,10 @@ const hoisted = vi.hoisted(() => {
3637
};
3738
});
3839

40+
vi.mock("openclaw/plugin-sdk/memory-host-search", () => ({
41+
closeActiveMemorySearchManager: hoisted.closeActiveMemorySearchManager,
42+
}));
43+
3944
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
4045
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
4146
"openclaw/plugin-sdk/session-store-runtime",
@@ -2811,6 +2816,37 @@ describe("active-memory plugin", () => {
28112816
expectLinesNotToContain(infoLines, " cached ");
28122817
});
28132818

2819+
it("releases memory search managers after active-memory timeouts", async () => {
2820+
testing.setMinimumTimeoutMsForTests(1);
2821+
testing.setSetupGraceTimeoutMsForTests(0);
2822+
api.pluginConfig = {
2823+
agents: ["main"],
2824+
timeoutMs: 1,
2825+
logging: true,
2826+
};
2827+
plugin.register(api as unknown as OpenClawPluginApi);
2828+
runEmbeddedPiAgent.mockImplementationOnce(() => new Promise<never>(() => {}));
2829+
2830+
const result = await hooks.before_prompt_build(
2831+
{ prompt: "what wings should i order? cleanup timeout", messages: [] },
2832+
{
2833+
agentId: "main",
2834+
trigger: "user",
2835+
sessionKey: "agent:main:cleanup-timeout",
2836+
messageProvider: "webchat",
2837+
},
2838+
);
2839+
2840+
expect(result).toBeUndefined();
2841+
await vi.waitFor(() => {
2842+
expect(hoisted.closeActiveMemorySearchManager).toHaveBeenCalledTimes(1);
2843+
});
2844+
expect(hoisted.closeActiveMemorySearchManager).toHaveBeenCalledWith({
2845+
cfg: configFile,
2846+
agentId: "main",
2847+
});
2848+
});
2849+
28142850
it("does not share cached recall results across session-id-only contexts", async () => {
28152851
api.pluginConfig = {
28162852
agents: ["main"],

extensions/active-memory/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
resolveDefaultModelForAgent,
1313
} from "openclaw/plugin-sdk/agent-runtime";
1414
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
15+
import { closeActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
1516
import {
1617
resolveLivePluginConfigObject,
1718
resolvePluginConfigObject,
@@ -980,6 +981,39 @@ function applyActiveMemoryRuntimeConfigSnapshot(
980981
};
981982
}
982983

984+
function resolveActiveMemoryCleanupConfig(api: OpenClawPluginApi): OpenClawConfig | undefined {
985+
try {
986+
return (
987+
(api.runtime.config?.current?.() as OpenClawConfig | undefined) ??
988+
(api.config as OpenClawConfig | undefined)
989+
);
990+
} catch {
991+
return api.config as OpenClawConfig | undefined;
992+
}
993+
}
994+
995+
function scheduleMemorySearchCleanupAfterTimeout(
996+
api: OpenClawPluginApi,
997+
logPrefix: string,
998+
agentId: string,
999+
): void {
1000+
const cfg = resolveActiveMemoryCleanupConfig(api);
1001+
setTimeout(() => {
1002+
void closeActiveMemorySearchManager({ cfg: cfg ?? api.config, agentId })
1003+
.then(() => {
1004+
api.logger.debug?.(`${logPrefix} released memory search managers after timeout`);
1005+
})
1006+
.catch((error: unknown) => {
1007+
const message = toSingleLineLogValue(
1008+
error instanceof Error ? error.message : String(error),
1009+
);
1010+
api.logger.warn?.(
1011+
`${logPrefix} failed to release memory search managers after timeout: ${message}`,
1012+
);
1013+
});
1014+
}, 0);
1015+
}
1016+
9831017
function resolveThinkingLevel(thinking: unknown): ActiveMemoryThinkingLevel {
9841018
if (
9851019
thinking === "off" ||
@@ -2755,6 +2789,7 @@ async function maybeResolveActiveRecall(params: {
27552789
searchDebug: result.searchDebug,
27562790
});
27572791
recordCircuitBreakerTimeout(cbKey);
2792+
scheduleMemorySearchCleanupAfterTimeout(params.api, logPrefix, params.agentId);
27582793
return result;
27592794
}
27602795

@@ -2864,6 +2899,7 @@ async function maybeResolveActiveRecall(params: {
28642899
searchDebug: result.searchDebug,
28652900
});
28662901
recordCircuitBreakerTimeout(cbKey);
2902+
scheduleMemorySearchCleanupAfterTimeout(params.api, logPrefix, params.agentId);
28672903
return result;
28682904
}
28692905
const message = toSingleLineLogValue(error instanceof Error ? error.message : String(error));

extensions/memory-core/index.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
2-
import { describe, expect, it } from "vitest";
2+
import type { MemoryPluginRuntime } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
3+
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
35
import {
46
buildMemoryFlushPlan,
57
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES,
@@ -8,6 +10,33 @@ import {
810
} from "./src/flush-plan.js";
911
import { buildPromptSection } from "./src/prompt-section.js";
1012

13+
const closeMemorySearchManagerMock = vi.hoisted(() => vi.fn(async () => {}));
14+
15+
vi.mock("./src/runtime-provider.js", () => ({
16+
memoryRuntime: {
17+
closeAllMemorySearchManagers: vi.fn(async () => {}),
18+
closeMemorySearchManager: closeMemorySearchManagerMock,
19+
getMemorySearchManager: vi.fn(async () => null),
20+
},
21+
}));
22+
23+
import plugin from "./index.js";
24+
25+
function registerMemoryCoreRuntime(): MemoryPluginRuntime {
26+
let runtime: MemoryPluginRuntime | undefined;
27+
plugin.register(
28+
createTestPluginApi({
29+
registerMemoryCapability(capability) {
30+
runtime = capability.runtime;
31+
},
32+
}),
33+
);
34+
if (!runtime) {
35+
throw new Error("expected memory-core to register a memory runtime");
36+
}
37+
return runtime;
38+
}
39+
1140
describe("buildPromptSection", () => {
1241
it("returns empty when no memory tools are available", () => {
1342
expect(buildPromptSection({ availableTools: new Set() })).toStrictEqual([]);
@@ -53,6 +82,21 @@ describe("buildPromptSection", () => {
5382
});
5483
});
5584

85+
describe("memory-core plugin runtime registration", () => {
86+
beforeEach(() => {
87+
vi.clearAllMocks();
88+
});
89+
90+
it("wires scoped memory search cleanup through the lazy runtime", async () => {
91+
const runtime = registerMemoryCoreRuntime();
92+
const cfg = {} as OpenClawConfig;
93+
94+
await runtime.closeMemorySearchManager?.({ cfg, agentId: "main" });
95+
96+
expect(closeMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" });
97+
});
98+
});
99+
56100
describe("buildMemoryFlushPlan", () => {
57101
const cfg = {
58102
agents: {

extensions/memory-core/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ const memoryRuntime: MemoryPluginRuntime = {
166166
const { memoryRuntime: runtime } = await loadRuntimeProviderModule();
167167
await runtime.closeAllMemorySearchManagers?.();
168168
},
169+
async closeMemorySearchManager(params) {
170+
const { memoryRuntime: runtime } = await loadRuntimeProviderModule();
171+
await runtime.closeMemorySearchManager?.(params);
172+
},
169173
};
170174
export default definePluginEntry({
171175
id: "memory-core",
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
export { closeAllMemoryIndexManagers, MemoryIndexManager } from "./src/memory/manager-runtime.js";
1+
export {
2+
closeAllMemoryIndexManagers,
3+
closeMemoryIndexManagersForAgent,
4+
MemoryIndexManager,
5+
} from "./src/memory/manager-runtime.js";

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

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
1212
import "./test-runtime-mocks.js";
1313
import type { MemoryIndexManager } from "./index.js";
1414
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
15-
import { EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
15+
import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
1616
import {
1717
DEFAULT_LOCAL_MODEL,
1818
registerBuiltInMemoryEmbeddingProviders,
@@ -28,6 +28,9 @@ afterAll(() => {
2828

2929
let embedBatchCalls = 0;
3030
let embedBatchInputCalls = 0;
31+
let providerCloseCalls = 0;
32+
let providerCloseFailuresRemaining = 0;
33+
let providerCloseGate: Promise<void> | null = null;
3134
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
3235
let forceNoProvider = false;
3336

@@ -65,6 +68,14 @@ vi.mock("./embeddings.js", () => {
6568
provider: {
6669
id: providerId,
6770
model,
71+
close: async () => {
72+
providerCloseCalls += 1;
73+
await providerCloseGate;
74+
if (providerCloseFailuresRemaining > 0) {
75+
providerCloseFailuresRemaining -= 1;
76+
throw new Error("provider close failed");
77+
}
78+
},
6879
embedQuery: async (text: string) => embedText(text),
6980
embedBatch: async (texts: string[]) => {
7081
embedBatchCalls += 1;
@@ -188,6 +199,9 @@ describe("memory index", () => {
188199
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
189200
embedBatchCalls = 0;
190201
embedBatchInputCalls = 0;
202+
providerCloseCalls = 0;
203+
providerCloseFailuresRemaining = 0;
204+
providerCloseGate = null;
191205
providerCalls = [];
192206
forceNoProvider = false;
193207

@@ -354,6 +368,96 @@ describe("memory index", () => {
354368
}
355369
});
356370

371+
it("closes embedding providers when memory index managers close", async () => {
372+
const cfg = createCfg({
373+
storePath: indexMainPath,
374+
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
375+
});
376+
const manager = await getFreshManager(cfg);
377+
378+
await manager.probeEmbeddingAvailability();
379+
expect(providerCloseCalls).toBe(0);
380+
381+
await manager.close();
382+
await manager.close();
383+
384+
expect(providerCloseCalls).toBe(1);
385+
});
386+
387+
it("closes embedding providers before waiting for pending sync to settle", async () => {
388+
const cfg = createCfg({
389+
storePath: indexMainPath,
390+
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
391+
});
392+
const manager = await getFreshManager(cfg);
393+
await manager.probeEmbeddingAvailability();
394+
let resolveSync: () => void = () => {};
395+
(manager as unknown as { syncing: Promise<void> }).syncing = new Promise<void>((resolve) => {
396+
resolveSync = resolve;
397+
});
398+
399+
const closePromise = manager.close();
400+
await vi.waitFor(() => {
401+
expect(providerCloseCalls).toBe(1);
402+
});
403+
let closeSettled = false;
404+
void closePromise.then(() => {
405+
closeSettled = true;
406+
});
407+
await Promise.resolve();
408+
409+
expect(closeSettled).toBe(false);
410+
resolveSync();
411+
await closePromise;
412+
});
413+
414+
it("evicts scoped memory index managers before close settles", async () => {
415+
let releaseProviderClose: () => void = () => {};
416+
providerCloseGate = new Promise<void>((resolve) => {
417+
releaseProviderClose = resolve;
418+
});
419+
const cfg = createCfg({
420+
storePath: indexMainPath,
421+
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
422+
});
423+
const first = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
424+
managersForCleanup.add(first);
425+
await first.probeEmbeddingAvailability();
426+
const closePromise = closeMemoryIndexManagersForAgent({ cfg, agentId: "main" });
427+
let second: MemoryIndexManager | null = null;
428+
try {
429+
await vi.waitFor(() => {
430+
expect(providerCloseCalls).toBe(1);
431+
});
432+
433+
second = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
434+
managersForCleanup.add(second);
435+
expect(second).not.toBe(first);
436+
} finally {
437+
releaseProviderClose();
438+
providerCloseGate = null;
439+
}
440+
await closePromise;
441+
442+
const third = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
443+
managersForCleanup.add(third);
444+
expect(third).toBe(second);
445+
});
446+
447+
it("retries embedding provider close before releasing the manager", async () => {
448+
providerCloseFailuresRemaining = 1;
449+
const cfg = createCfg({
450+
storePath: indexMainPath,
451+
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
452+
});
453+
const manager = await getFreshManager(cfg);
454+
455+
await manager.probeEmbeddingAvailability();
456+
await manager.close();
457+
458+
expect(providerCloseCalls).toBe(2);
459+
});
460+
357461
it("indexes multimodal image and audio files from extra paths with Gemini structured inputs", async () => {
358462
const mediaDir = path.join(workspaceDir, "media-memory");
359463
await fs.mkdir(mediaDir, { recursive: true });

extensions/memory-core/src/memory/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type {
66
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
77
export {
88
closeAllMemorySearchManagers,
9+
closeMemorySearchManager,
910
getMemorySearchManager,
1011
type MemorySearchManagerPurpose,
1112
type MemorySearchManagerResult,
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
export { closeAllMemoryIndexManagers, MemoryIndexManager } from "./manager.js";
1+
export {
2+
closeAllMemoryIndexManagers,
3+
closeMemoryIndexManagersForAgent,
4+
MemoryIndexManager,
5+
} from "./manager.js";

0 commit comments

Comments
 (0)