Skip to content

Commit 0bcf7df

Browse files
masatohoshinoclaude
andcommitted
fix(status): surface unregistered memory embedding providers
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 18b2ff6 commit 0bcf7df

8 files changed

Lines changed: 262 additions & 13 deletions

src/gateway/server-startup-plugins.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
44
import { initSubagentRegistry } from "../agents/subagent-registry.js";
55
import type { OpenClawConfig } from "../config/types.openclaw.js";
6-
import { collectUnregisteredConfiguredMemoryEmbeddingProviders } from "../plugins/channel-plugin-ids.js";
7-
import { listRegisteredEmbeddingProviders } from "../plugins/embedding-providers.js";
6+
import {
7+
collectRegisteredEmbeddingProviderIds,
8+
collectUnregisteredConfiguredMemoryEmbeddingProviders,
9+
} from "../plugins/channel-plugin-ids.js";
810
import { loadPluginLookUpTable } from "../plugins/plugin-lookup-table.js";
911
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
1012
import type { PluginRegistry, PluginRegistryParams } from "../plugins/registry-types.js";
@@ -194,16 +196,9 @@ export function warnUnregisteredConfiguredMemoryEmbeddingProviders(params: {
194196
pluginRegistry: Partial<Pick<PluginRegistry, "embeddingProviders" | "memoryEmbeddingProviders">>;
195197
log: Pick<GatewayPluginBootstrapLog, "warn">;
196198
}): void {
197-
const registeredProviderIds = new Set(
198-
[
199-
...(params.pluginRegistry.memoryEmbeddingProviders ?? []),
200-
...(params.pluginRegistry.embeddingProviders ?? []),
201-
...listRegisteredEmbeddingProviders().map((entry) => ({ provider: entry.adapter })),
202-
].map((entry) => entry.provider.id),
203-
);
204199
const unregistered = collectUnregisteredConfiguredMemoryEmbeddingProviders({
205200
config: params.config,
206-
registeredProviderIds,
201+
registeredProviderIds: collectRegisteredEmbeddingProviderIds(params.pluginRegistry),
207202
});
208203
for (const provider of unregistered) {
209204
const path = `memorySearch.${provider.source}`;

src/plugins/channel-plugin-ids.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export {
1616
export {
1717
collectConfiguredMemoryEmbeddingProviderIds,
1818
collectConfiguredMemoryEmbeddingStartupProviderOwners,
19+
collectRegisteredEmbeddingProviderIds,
1920
collectUnregisteredConfiguredMemoryEmbeddingProviders,
2021
resolveChannelPluginIds,
2122
resolveChannelPluginIdsFromRegistry,

src/plugins/embedding-providers.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Covers plugin embedding provider registration and lookup.
22
import { afterEach, beforeEach, describe, expect, it } from "vitest";
3+
import { collectRegisteredEmbeddingProviderIds } from "./channel-plugin-ids.js";
34
import {
45
clearEmbeddingProviders,
56
getEmbeddingProvider,
@@ -108,3 +109,34 @@ describe("embedding provider registry", () => {
108109
});
109110
});
110111
});
112+
113+
describe("collectRegisteredEmbeddingProviderIds", () => {
114+
// Boot-equivalence: the shared helper unions the same three sources the gateway
115+
// startup "configured but unregistered" warning uses, so the /status drift line and
116+
// the boot warning agree on what counts as "registered".
117+
it("unions registry memory + general embedding providers with the global registry", () => {
118+
registerEmbeddingProvider(createAdapter("global-embed"), { ownerPluginId: "p" });
119+
const registry = {
120+
memoryEmbeddingProviders: [{ provider: { id: "mem-embed" } }],
121+
embeddingProviders: [{ provider: { id: "gen-embed" } }],
122+
} as never;
123+
124+
const ids = collectRegisteredEmbeddingProviderIds(registry);
125+
126+
expect(ids.has("mem-embed")).toBe(true);
127+
expect(ids.has("gen-embed")).toBe(true);
128+
expect(ids.has("global-embed")).toBe(true);
129+
// Every globally registered provider (core + plugin-registered) is always included.
130+
for (const entry of listRegisteredEmbeddingProviders()) {
131+
expect(ids.has(entry.adapter.id)).toBe(true);
132+
}
133+
});
134+
135+
it("returns only the global registry ids when the runtime registry omits embedding providers", () => {
136+
const ids = collectRegisteredEmbeddingProviderIds({});
137+
138+
expect(ids).toEqual(
139+
new Set(listRegisteredEmbeddingProviders().map((entry) => entry.adapter.id)),
140+
);
141+
});
142+
});

src/plugins/gateway-startup-plugin-ids.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { normalizePluginsConfigWithResolver } from "./config-normalization-share
2727
import { resolveEffectivePluginActivationState } from "./config-state.js";
2828
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
2929
import { resolveConfiguredGenericEmbeddingProviderId } from "./embedding-provider-config.js";
30+
import { listRegisteredEmbeddingProviders } from "./embedding-providers.js";
3031
import {
3132
collectConfiguredSpeechProviderIds,
3233
normalizeConfiguredSpeechProviderIdForStartup,
@@ -50,6 +51,7 @@ import {
5051
} from "./plugin-registry-contributions.js";
5152
import type { PluginRegistrySnapshot } from "./plugin-registry-snapshot.js";
5253
import { normalizePluginIdScope } from "./plugin-scope.js";
54+
import type { PluginRegistry } from "./registry-types.js";
5355

5456
export type GatewayStartupPluginPlan = {
5557
channelPluginIds: readonly string[];
@@ -754,6 +756,24 @@ export function collectUnregisteredConfiguredMemoryEmbeddingProviders(params: {
754756
);
755757
}
756758

759+
// Registered embedding provider ids the loaded runtime can actually serve: the live
760+
// registry's memory + general embedding providers plus the global/core embedding
761+
// registry. Shared by gateway boot (the startup "configured but unregistered" warning)
762+
// and the `/status plugins` drift line so both agree on what counts as "registered" and
763+
// never diverge. The `{ provider: entry.adapter }` wrap makes the core registry entries
764+
// match the registration shape so the id projection stays uniform across all three sources.
765+
export function collectRegisteredEmbeddingProviderIds(
766+
registry: Partial<Pick<PluginRegistry, "embeddingProviders" | "memoryEmbeddingProviders">>,
767+
): Set<string> {
768+
return new Set(
769+
[
770+
...(registry.memoryEmbeddingProviders ?? []),
771+
...(registry.embeddingProviders ?? []),
772+
...listRegisteredEmbeddingProviders().map((entry) => ({ provider: entry.adapter })),
773+
].map((entry) => entry.provider.id),
774+
);
775+
}
776+
757777
function addPluginConfigEntryIds(
758778
target: Set<string>,
759779
plugins: ReturnType<typeof normalizePluginsConfigForInstalledIndex>,

src/status/status-plugin-health.installed.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
} from "../config/runtime-snapshot.js";
1010
import { resolveGatewayStartupPluginActivationConfig } from "../gateway/plugin-activation-runtime-config.js";
1111
import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js";
12-
import { loadGatewayStartupPluginPlan } from "../plugins/gateway-startup-plugin-ids.js";
12+
import {
13+
collectUnregisteredConfiguredMemoryEmbeddingProviders,
14+
loadGatewayStartupPluginPlan,
15+
} from "../plugins/gateway-startup-plugin-ids.js";
1316
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
1417
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
1518
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
@@ -36,7 +39,14 @@ vi.mock("../plugins/status.js", async (importOriginal) => {
3639
// eager importer in the graph keeps working.
3740
vi.mock("../plugins/gateway-startup-plugin-ids.js", async (importOriginal) => {
3841
const actual = await importOriginal<typeof import("../plugins/gateway-startup-plugin-ids.js")>();
39-
return { ...actual, loadGatewayStartupPluginPlan: vi.fn() } as typeof actual;
42+
return {
43+
...actual,
44+
loadGatewayStartupPluginPlan: vi.fn(),
45+
// Default to no unregistered providers so the should-run tests are unaffected; the
46+
// memory-provider tests below override per case. collectRegisteredEmbeddingProviderIds
47+
// stays real (it just reads the seeded registry + core embedding registry).
48+
collectUnregisteredConfiguredMemoryEmbeddingProviders: vi.fn(() => []),
49+
} as typeof actual;
4050
});
4151
// The startup-plan activation assembly is the gateway's own shared helper; mock it to
4252
// identity (return the runtime config) so the status wiring stays deterministic. The helper's
@@ -62,11 +72,17 @@ const loadGatewayStartupPluginPlanMock = vi.mocked(loadGatewayStartupPluginPlan)
6272
const resolveGatewayStartupPluginActivationConfigMock = vi.mocked(
6373
resolveGatewayStartupPluginActivationConfig,
6474
);
75+
const collectUnregisteredConfiguredMemoryEmbeddingProvidersMock = vi.mocked(
76+
collectUnregisteredConfiguredMemoryEmbeddingProviders,
77+
);
6578

6679
afterEach(() => {
6780
resolveReadOnlyChannelPluginsForConfigMock.mockReset();
6881
loadGatewayStartupPluginPlanMock.mockReset();
6982
resolveGatewayStartupPluginActivationConfigMock.mockClear();
83+
collectUnregisteredConfiguredMemoryEmbeddingProvidersMock.mockReset();
84+
// Re-establish the empty default so the next test starts with no unregistered providers.
85+
collectUnregisteredConfiguredMemoryEmbeddingProvidersMock.mockReturnValue([]);
7086
clearRuntimeConfigSnapshot();
7187
resetPluginRuntimeStateForTest();
7288
resetPluginStateStoreForTests();
@@ -166,3 +182,87 @@ describe("installed plugin health should-run drift", () => {
166182
});
167183
});
168184
});
185+
186+
describe("installed plugin health unregistered memory embedding providers", () => {
187+
it("surfaces configured memory embedding providers the runtime registry does not register", async () => {
188+
await withStateDirEnv("openclaw-status-memory-embed-", async () => {
189+
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
190+
loadFailures: [],
191+
missingConfiguredChannelIds: [],
192+
} as never);
193+
loadGatewayStartupPluginPlanMock.mockReturnValue({
194+
channelPluginIds: [],
195+
configuredDeferredChannelPluginIds: [],
196+
pluginIds: [],
197+
});
198+
collectUnregisteredConfiguredMemoryEmbeddingProvidersMock.mockReturnValue([
199+
{ configuredId: "custom-embed", source: "provider" },
200+
]);
201+
setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws");
202+
203+
const snapshot = await collectInstalledPluginHealthSnapshot({
204+
config: {} as never,
205+
workspaceDir: "/tmp/ws",
206+
});
207+
208+
expect(snapshot.unregisteredMemoryEmbeddingProviders).toEqual([
209+
{ configuredId: "custom-embed", source: "provider" },
210+
]);
211+
// The mismatch is checked against the live registry's embedding providers (collected
212+
// into a Set), so a CLI/empty-registry process can never false-report "unregistered".
213+
expect(collectUnregisteredConfiguredMemoryEmbeddingProvidersMock).toHaveBeenCalledWith(
214+
expect.objectContaining({ registeredProviderIds: expect.any(Set) }),
215+
);
216+
expect(formatDetailedPluginHealth(snapshot)).toContain(
217+
"Configured memory provider not registered: 1 (custom-embed (memorySearch.provider))",
218+
);
219+
});
220+
});
221+
222+
it("skips the check and renders no line when no runtime registry is active", async () => {
223+
await withStateDirEnv("openclaw-status-memory-embed-no-registry-", async () => {
224+
// No active runtime registry (a fresh CLI process that never started a gateway).
225+
resetPluginRuntimeStateForTest();
226+
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
227+
loadFailures: [],
228+
missingConfiguredChannelIds: [],
229+
} as never);
230+
loadGatewayStartupPluginPlanMock.mockReturnValue({
231+
channelPluginIds: [],
232+
configuredDeferredChannelPluginIds: [],
233+
pluginIds: [],
234+
});
235+
// Even if the resolver would report something, the null-registry guard must skip it
236+
// (a CLI/empty-registry process must never false-report "unregistered").
237+
collectUnregisteredConfiguredMemoryEmbeddingProvidersMock.mockReturnValue([
238+
{ configuredId: "custom-embed", source: "provider" },
239+
]);
240+
241+
const snapshot = await collectInstalledPluginHealthSnapshot({
242+
config: {} as never,
243+
workspaceDir: "/tmp/ws",
244+
});
245+
246+
expect(snapshot.unregisteredMemoryEmbeddingProviders).toBeUndefined();
247+
expect(collectUnregisteredConfiguredMemoryEmbeddingProvidersMock).not.toHaveBeenCalled();
248+
expect(formatDetailedPluginHealth(snapshot)).not.toContain(
249+
"Configured memory provider not registered:",
250+
);
251+
});
252+
});
253+
254+
it("omits the check when no config is provided", async () => {
255+
await withStateDirEnv("openclaw-status-memory-embed-no-config-", async () => {
256+
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
257+
loadFailures: [],
258+
missingConfiguredChannelIds: [],
259+
} as never);
260+
setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws");
261+
262+
const snapshot = await collectInstalledPluginHealthSnapshot({ workspaceDir: "/tmp/ws" });
263+
264+
expect(snapshot.unregisteredMemoryEmbeddingProviders).toBeUndefined();
265+
expect(collectUnregisteredConfiguredMemoryEmbeddingProvidersMock).not.toHaveBeenCalled();
266+
});
267+
});
268+
});

src/status/status-plugin-health.runtime.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,43 @@ export async function collectInstalledPluginHealthSnapshot(params: {
223223
{ ...runtime, compatibilityNotices: runtimeCompatibilityNotices },
224224
);
225225
const shouldRunPluginIds = await resolveEagerShouldRunPluginIds(params);
226-
return shouldRunPluginIds ? { ...merged, shouldRunPluginIds } : merged;
226+
const unregisteredMemoryEmbeddingProviders = await resolveUnregisteredMemoryEmbeddingProviders({
227+
config: params.config,
228+
registry: runtimeRegistry,
229+
});
230+
return {
231+
...merged,
232+
...(shouldRunPluginIds ? { shouldRunPluginIds } : {}),
233+
...(unregisteredMemoryEmbeddingProviders ? { unregisteredMemoryEmbeddingProviders } : {}),
234+
};
235+
}
236+
237+
// Configured memory embedding providers that no loaded plugin registers, surfaced on the
238+
// detailed /status path only. Needs the live runtime registry to know what a loaded plugin
239+
// actually serves; without config or an active registry we cannot tell "configured but
240+
// unavailable" from "not yet loaded", so degrade to no signal (no line). Resolved lazily so
241+
// the compact path never pulls the startup-plan module. Observer-only: any resolution failure
242+
// degrades to no set rather than breaking /status.
243+
async function resolveUnregisteredMemoryEmbeddingProviders(params: {
244+
config?: OpenClawConfig;
245+
registry: ReturnType<typeof getActiveRuntimePluginRegistry>;
246+
}): Promise<Array<{ configuredId: string; source: "provider" | "fallback" }> | undefined> {
247+
if (!params.config || !params.registry) {
248+
return undefined;
249+
}
250+
try {
251+
const {
252+
collectRegisteredEmbeddingProviderIds,
253+
collectUnregisteredConfiguredMemoryEmbeddingProviders,
254+
} = await import("../plugins/gateway-startup-plugin-ids.js");
255+
const unregistered = collectUnregisteredConfiguredMemoryEmbeddingProviders({
256+
config: params.config,
257+
registeredProviderIds: collectRegisteredEmbeddingProviderIds(params.registry),
258+
});
259+
return unregistered.length > 0 ? unregistered : undefined;
260+
} catch {
261+
return undefined;
262+
}
227263
}
228264

229265
// Eager should-run plugin ids from the gateway startup plan, with deferred channel

src/status/status-plugin-health.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,4 +395,42 @@ describe("plugin health status formatting", () => {
395395
expect(text).toContain("Installed (not active): 1 (installed-idle)");
396396
expect(text).not.toContain("Configured to run but not loaded:");
397397
});
398+
399+
it("flags configured memory embedding providers that no loaded plugin registers", () => {
400+
const text = formatDetailedPluginHealth({
401+
plugins: [{ id: "runtime-ok", status: "loaded", enabled: true }],
402+
diagnostics: [],
403+
contextEngineQuarantines: [],
404+
runtimeLoadedPluginIds: ["runtime-ok"],
405+
unregisteredMemoryEmbeddingProviders: [
406+
{ configuredId: "custom-embed", source: "provider" },
407+
{ configuredId: "fallback-embed", source: "fallback" },
408+
],
409+
});
410+
411+
expect(text).toContain(
412+
"Configured memory provider not registered: 2 (custom-embed (memorySearch.provider), fallback-embed (memorySearch.fallback))",
413+
);
414+
// Observer-only: the unregistered-provider signal never enters the compact line.
415+
expect(text.split("\n")[0]).toBe("🔌 Plugins: OK");
416+
});
417+
418+
it("omits the memory-provider line when none are unregistered or the field is absent", () => {
419+
const withEmpty = formatDetailedPluginHealth({
420+
plugins: [{ id: "runtime-ok", status: "loaded", enabled: true }],
421+
diagnostics: [],
422+
contextEngineQuarantines: [],
423+
runtimeLoadedPluginIds: ["runtime-ok"],
424+
unregisteredMemoryEmbeddingProviders: [],
425+
});
426+
const withAbsent = formatDetailedPluginHealth({
427+
plugins: [{ id: "runtime-ok", status: "loaded", enabled: true }],
428+
diagnostics: [],
429+
contextEngineQuarantines: [],
430+
runtimeLoadedPluginIds: ["runtime-ok"],
431+
});
432+
433+
expect(withEmpty).not.toContain("Configured memory provider not registered:");
434+
expect(withAbsent).not.toContain("Configured memory provider not registered:");
435+
});
398436
});

src/status/status-plugin-health.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ export type StatusPluginHealthSnapshot = {
7171
// is not in the runtime-loaded set. Absent on compact/hand-built snapshots, where
7272
// no drift line is rendered (back-compat).
7373
shouldRunPluginIds?: string[];
74+
// Configured memory embedding providers (memorySearch provider/fallback) that no
75+
// loaded plugin registers, so semantic memory recall silently falls back to
76+
// keyword/FTS-only. Detailed-status only; absent on compact/hand-built snapshots and
77+
// whenever the live runtime registry is unavailable, so no line renders (back-compat).
78+
// `source` mirrors MemoryEmbeddingStartupProviderSource ("provider" | "fallback").
79+
unregisteredMemoryEmbeddingProviders?: Array<{
80+
configuredId: string;
81+
source: "provider" | "fallback";
82+
}>;
7483
};
7584

7685
/** Keeps the first record per key; later duplicates are dropped. */
@@ -307,6 +316,12 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot)
307316
const channelPluginFailures = (snapshot.channelPluginFailures ?? []).toSorted((left, right) =>
308317
byLocale(left.channelId, right.channelId),
309318
);
319+
const unregisteredMemoryProviders = (
320+
snapshot.unregisteredMemoryEmbeddingProviders ?? []
321+
).toSorted(
322+
(left, right) =>
323+
byLocale(left.configuredId, right.configuredId) || byLocale(left.source, right.source),
324+
);
310325
const lines = [
311326
formatCompactPluginHealthLine(snapshot),
312327
`Loaded: ${loaded.length}${loaded.length > 0 ? ` (${formatPluginList(loaded, 8)})` : ""}`,
@@ -332,6 +347,18 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot)
332347
);
333348
}
334349

350+
if (unregisteredMemoryProviders.length > 0) {
351+
// A configured memory embedding provider that no loaded plugin registers: semantic
352+
// memory recall silently falls back to keyword/FTS-only. Observer-only signal, distinct
353+
// from plugin load/error state and not counted in the compact line.
354+
const display = unregisteredMemoryProviders.map(
355+
(entry) => `${entry.configuredId} (memorySearch.${entry.source})`,
356+
);
357+
lines.push(
358+
`Configured memory provider not registered: ${unregisteredMemoryProviders.length} (${formatPluginList(display, 8)})`,
359+
);
360+
}
361+
335362
if (errors.length > 0) {
336363
lines.push(
337364
`Errors: ${errors.length}`,

0 commit comments

Comments
 (0)