From 0a9708ed0ffa52cddb2dcfd3a4b4b5830b8a0a28 Mon Sep 17 00:00:00 2001 From: Masato Hoshino Date: Wed, 1 Jul 2026 16:46:49 +0900 Subject: [PATCH] fix(status): surface unregistered memory embedding providers (#97968) --- src/gateway/server-startup-plugins.ts | 15 +-- src/plugins/channel-plugin-ids.ts | 1 + src/plugins/embedding-providers.test.ts | 32 ++++++ src/plugins/gateway-startup-plugin-ids.ts | 20 ++++ .../status-plugin-health.installed.test.ts | 104 +++++++++++++++++- src/status/status-plugin-health.runtime.ts | 38 ++++++- src/status/status-plugin-health.test.ts | 38 +++++++ src/status/status-plugin-health.ts | 27 +++++ 8 files changed, 262 insertions(+), 13 deletions(-) diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index f8e817e8de85..4bf2b5724f3d 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -3,8 +3,10 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { initSubagentRegistry } from "../agents/subagent-registry.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { collectUnregisteredConfiguredMemoryEmbeddingProviders } from "../plugins/channel-plugin-ids.js"; -import { listRegisteredEmbeddingProviders } from "../plugins/embedding-providers.js"; +import { + collectRegisteredEmbeddingProviderIds, + collectUnregisteredConfiguredMemoryEmbeddingProviders, +} from "../plugins/channel-plugin-ids.js"; import { loadPluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import type { PluginRegistry, PluginRegistryParams } from "../plugins/registry-types.js"; @@ -194,16 +196,9 @@ export function warnUnregisteredConfiguredMemoryEmbeddingProviders(params: { pluginRegistry: Partial>; log: Pick; }): void { - const registeredProviderIds = new Set( - [ - ...(params.pluginRegistry.memoryEmbeddingProviders ?? []), - ...(params.pluginRegistry.embeddingProviders ?? []), - ...listRegisteredEmbeddingProviders().map((entry) => ({ provider: entry.adapter })), - ].map((entry) => entry.provider.id), - ); const unregistered = collectUnregisteredConfiguredMemoryEmbeddingProviders({ config: params.config, - registeredProviderIds, + registeredProviderIds: collectRegisteredEmbeddingProviderIds(params.pluginRegistry), }); for (const provider of unregistered) { const path = `memorySearch.${provider.source}`; diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index 98e7d0d35297..266b8caf3249 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -16,6 +16,7 @@ export { export { collectConfiguredMemoryEmbeddingProviderIds, collectConfiguredMemoryEmbeddingStartupProviderOwners, + collectRegisteredEmbeddingProviderIds, collectUnregisteredConfiguredMemoryEmbeddingProviders, resolveChannelPluginIds, resolveChannelPluginIdsFromRegistry, diff --git a/src/plugins/embedding-providers.test.ts b/src/plugins/embedding-providers.test.ts index a4c99034ddfd..99d455620c5e 100644 --- a/src/plugins/embedding-providers.test.ts +++ b/src/plugins/embedding-providers.test.ts @@ -1,5 +1,6 @@ // Covers plugin embedding provider registration and lookup. import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { collectRegisteredEmbeddingProviderIds } from "./channel-plugin-ids.js"; import { clearEmbeddingProviders, getEmbeddingProvider, @@ -108,3 +109,34 @@ describe("embedding provider registry", () => { }); }); }); + +describe("collectRegisteredEmbeddingProviderIds", () => { + // Boot-equivalence: the shared helper unions the same three sources the gateway + // startup "configured but unregistered" warning uses, so the /status drift line and + // the boot warning agree on what counts as "registered". + it("unions registry memory + general embedding providers with the global registry", () => { + registerEmbeddingProvider(createAdapter("global-embed"), { ownerPluginId: "p" }); + const registry = { + memoryEmbeddingProviders: [{ provider: { id: "mem-embed" } }], + embeddingProviders: [{ provider: { id: "gen-embed" } }], + } as never; + + const ids = collectRegisteredEmbeddingProviderIds(registry); + + expect(ids.has("mem-embed")).toBe(true); + expect(ids.has("gen-embed")).toBe(true); + expect(ids.has("global-embed")).toBe(true); + // Every globally registered provider (core + plugin-registered) is always included. + for (const entry of listRegisteredEmbeddingProviders()) { + expect(ids.has(entry.adapter.id)).toBe(true); + } + }); + + it("returns only the global registry ids when the runtime registry omits embedding providers", () => { + const ids = collectRegisteredEmbeddingProviderIds({}); + + expect(ids).toEqual( + new Set(listRegisteredEmbeddingProviders().map((entry) => entry.adapter.id)), + ); + }); +}); diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 747a7cb4c60b..6b8e61d9b381 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -27,6 +27,7 @@ import { normalizePluginsConfigWithResolver } from "./config-normalization-share import { resolveEffectivePluginActivationState } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { resolveConfiguredGenericEmbeddingProviderId } from "./embedding-provider-config.js"; +import { listRegisteredEmbeddingProviders } from "./embedding-providers.js"; import { collectConfiguredSpeechProviderIds, normalizeConfiguredSpeechProviderIdForStartup, @@ -50,6 +51,7 @@ import { } from "./plugin-registry-contributions.js"; import type { PluginRegistrySnapshot } from "./plugin-registry-snapshot.js"; import { normalizePluginIdScope } from "./plugin-scope.js"; +import type { PluginRegistry } from "./registry-types.js"; export type GatewayStartupPluginPlan = { channelPluginIds: readonly string[]; @@ -754,6 +756,24 @@ export function collectUnregisteredConfiguredMemoryEmbeddingProviders(params: { ); } +// Registered embedding provider ids the loaded runtime can actually serve: the live +// registry's memory + general embedding providers plus the global/core embedding +// registry. Shared by gateway boot (the startup "configured but unregistered" warning) +// and the `/status plugins` drift line so both agree on what counts as "registered" and +// never diverge. The `{ provider: entry.adapter }` wrap makes the core registry entries +// match the registration shape so the id projection stays uniform across all three sources. +export function collectRegisteredEmbeddingProviderIds( + registry: Partial>, +): Set { + return new Set( + [ + ...(registry.memoryEmbeddingProviders ?? []), + ...(registry.embeddingProviders ?? []), + ...listRegisteredEmbeddingProviders().map((entry) => ({ provider: entry.adapter })), + ].map((entry) => entry.provider.id), + ); +} + function addPluginConfigEntryIds( target: Set, plugins: ReturnType, diff --git a/src/status/status-plugin-health.installed.test.ts b/src/status/status-plugin-health.installed.test.ts index 3efacd871f58..8f9180e59639 100644 --- a/src/status/status-plugin-health.installed.test.ts +++ b/src/status/status-plugin-health.installed.test.ts @@ -9,7 +9,10 @@ import { } from "../config/runtime-snapshot.js"; import { resolveGatewayStartupPluginActivationConfig } from "../gateway/plugin-activation-runtime-config.js"; import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js"; -import { loadGatewayStartupPluginPlan } from "../plugins/gateway-startup-plugin-ids.js"; +import { + collectUnregisteredConfiguredMemoryEmbeddingProviders, + loadGatewayStartupPluginPlan, +} from "../plugins/gateway-startup-plugin-ids.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; @@ -36,7 +39,14 @@ vi.mock("../plugins/status.js", async (importOriginal) => { // eager importer in the graph keeps working. vi.mock("../plugins/gateway-startup-plugin-ids.js", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, loadGatewayStartupPluginPlan: vi.fn() } as typeof actual; + return { + ...actual, + loadGatewayStartupPluginPlan: vi.fn(), + // Default to no unregistered providers so the should-run tests are unaffected; the + // memory-provider tests below override per case. collectRegisteredEmbeddingProviderIds + // stays real (it just reads the seeded registry + core embedding registry). + collectUnregisteredConfiguredMemoryEmbeddingProviders: vi.fn(() => []), + } as typeof actual; }); // The startup-plan activation assembly is the gateway's own shared helper; mock it to // identity (return the runtime config) so the status wiring stays deterministic. The helper's @@ -62,11 +72,17 @@ const loadGatewayStartupPluginPlanMock = vi.mocked(loadGatewayStartupPluginPlan) const resolveGatewayStartupPluginActivationConfigMock = vi.mocked( resolveGatewayStartupPluginActivationConfig, ); +const collectUnregisteredConfiguredMemoryEmbeddingProvidersMock = vi.mocked( + collectUnregisteredConfiguredMemoryEmbeddingProviders, +); afterEach(() => { resolveReadOnlyChannelPluginsForConfigMock.mockReset(); loadGatewayStartupPluginPlanMock.mockReset(); resolveGatewayStartupPluginActivationConfigMock.mockClear(); + collectUnregisteredConfiguredMemoryEmbeddingProvidersMock.mockReset(); + // Re-establish the empty default so the next test starts with no unregistered providers. + collectUnregisteredConfiguredMemoryEmbeddingProvidersMock.mockReturnValue([]); clearRuntimeConfigSnapshot(); resetPluginRuntimeStateForTest(); resetPluginStateStoreForTests(); @@ -166,3 +182,87 @@ describe("installed plugin health should-run drift", () => { }); }); }); + +describe("installed plugin health unregistered memory embedding providers", () => { + it("surfaces configured memory embedding providers the runtime registry does not register", async () => { + await withStateDirEnv("openclaw-status-memory-embed-", async () => { + resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({ + loadFailures: [], + missingConfiguredChannelIds: [], + } as never); + loadGatewayStartupPluginPlanMock.mockReturnValue({ + channelPluginIds: [], + configuredDeferredChannelPluginIds: [], + pluginIds: [], + }); + collectUnregisteredConfiguredMemoryEmbeddingProvidersMock.mockReturnValue([ + { configuredId: "custom-embed", source: "provider" }, + ]); + setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws"); + + const snapshot = await collectInstalledPluginHealthSnapshot({ + config: {} as never, + workspaceDir: "/tmp/ws", + }); + + expect(snapshot.unregisteredMemoryEmbeddingProviders).toEqual([ + { configuredId: "custom-embed", source: "provider" }, + ]); + // The mismatch is checked against the live registry's embedding providers (collected + // into a Set), so a CLI/empty-registry process can never false-report "unregistered". + expect(collectUnregisteredConfiguredMemoryEmbeddingProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ registeredProviderIds: expect.any(Set) }), + ); + expect(formatDetailedPluginHealth(snapshot)).toContain( + "Configured memory provider not registered: 1 (custom-embed (memorySearch.provider))", + ); + }); + }); + + it("skips the check and renders no line when no runtime registry is active", async () => { + await withStateDirEnv("openclaw-status-memory-embed-no-registry-", async () => { + // No active runtime registry (a fresh CLI process that never started a gateway). + resetPluginRuntimeStateForTest(); + resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({ + loadFailures: [], + missingConfiguredChannelIds: [], + } as never); + loadGatewayStartupPluginPlanMock.mockReturnValue({ + channelPluginIds: [], + configuredDeferredChannelPluginIds: [], + pluginIds: [], + }); + // Even if the resolver would report something, the null-registry guard must skip it + // (a CLI/empty-registry process must never false-report "unregistered"). + collectUnregisteredConfiguredMemoryEmbeddingProvidersMock.mockReturnValue([ + { configuredId: "custom-embed", source: "provider" }, + ]); + + const snapshot = await collectInstalledPluginHealthSnapshot({ + config: {} as never, + workspaceDir: "/tmp/ws", + }); + + expect(snapshot.unregisteredMemoryEmbeddingProviders).toBeUndefined(); + expect(collectUnregisteredConfiguredMemoryEmbeddingProvidersMock).not.toHaveBeenCalled(); + expect(formatDetailedPluginHealth(snapshot)).not.toContain( + "Configured memory provider not registered:", + ); + }); + }); + + it("omits the check when no config is provided", async () => { + await withStateDirEnv("openclaw-status-memory-embed-no-config-", async () => { + resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({ + loadFailures: [], + missingConfiguredChannelIds: [], + } as never); + setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws"); + + const snapshot = await collectInstalledPluginHealthSnapshot({ workspaceDir: "/tmp/ws" }); + + expect(snapshot.unregisteredMemoryEmbeddingProviders).toBeUndefined(); + expect(collectUnregisteredConfiguredMemoryEmbeddingProvidersMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/status/status-plugin-health.runtime.ts b/src/status/status-plugin-health.runtime.ts index a0c73cadb240..d6e5ea33f08d 100644 --- a/src/status/status-plugin-health.runtime.ts +++ b/src/status/status-plugin-health.runtime.ts @@ -223,7 +223,43 @@ export async function collectInstalledPluginHealthSnapshot(params: { { ...runtime, compatibilityNotices: runtimeCompatibilityNotices }, ); const shouldRunPluginIds = await resolveEagerShouldRunPluginIds(params); - return shouldRunPluginIds ? { ...merged, shouldRunPluginIds } : merged; + const unregisteredMemoryEmbeddingProviders = await resolveUnregisteredMemoryEmbeddingProviders({ + config: params.config, + registry: runtimeRegistry, + }); + return { + ...merged, + ...(shouldRunPluginIds ? { shouldRunPluginIds } : {}), + ...(unregisteredMemoryEmbeddingProviders ? { unregisteredMemoryEmbeddingProviders } : {}), + }; +} + +// Configured memory embedding providers that no loaded plugin registers, surfaced on the +// detailed /status path only. Needs the live runtime registry to know what a loaded plugin +// actually serves; without config or an active registry we cannot tell "configured but +// unavailable" from "not yet loaded", so degrade to no signal (no line). Resolved lazily so +// the compact path never pulls the startup-plan module. Observer-only: any resolution failure +// degrades to no set rather than breaking /status. +async function resolveUnregisteredMemoryEmbeddingProviders(params: { + config?: OpenClawConfig; + registry: ReturnType; +}): Promise | undefined> { + if (!params.config || !params.registry) { + return undefined; + } + try { + const { + collectRegisteredEmbeddingProviderIds, + collectUnregisteredConfiguredMemoryEmbeddingProviders, + } = await import("../plugins/gateway-startup-plugin-ids.js"); + const unregistered = collectUnregisteredConfiguredMemoryEmbeddingProviders({ + config: params.config, + registeredProviderIds: collectRegisteredEmbeddingProviderIds(params.registry), + }); + return unregistered.length > 0 ? unregistered : undefined; + } catch { + return undefined; + } } // Eager should-run plugin ids from the gateway startup plan, with deferred channel diff --git a/src/status/status-plugin-health.test.ts b/src/status/status-plugin-health.test.ts index aeb2d4bcb951..300f1abbb097 100644 --- a/src/status/status-plugin-health.test.ts +++ b/src/status/status-plugin-health.test.ts @@ -395,4 +395,42 @@ describe("plugin health status formatting", () => { expect(text).toContain("Installed (not active): 1 (installed-idle)"); expect(text).not.toContain("Configured to run but not loaded:"); }); + + it("flags configured memory embedding providers that no loaded plugin registers", () => { + const text = formatDetailedPluginHealth({ + plugins: [{ id: "runtime-ok", status: "loaded", enabled: true }], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["runtime-ok"], + unregisteredMemoryEmbeddingProviders: [ + { configuredId: "custom-embed", source: "provider" }, + { configuredId: "fallback-embed", source: "fallback" }, + ], + }); + + expect(text).toContain( + "Configured memory provider not registered: 2 (custom-embed (memorySearch.provider), fallback-embed (memorySearch.fallback))", + ); + // Observer-only: the unregistered-provider signal never enters the compact line. + expect(text.split("\n")[0]).toBe("🔌 Plugins: OK"); + }); + + it("omits the memory-provider line when none are unregistered or the field is absent", () => { + const withEmpty = formatDetailedPluginHealth({ + plugins: [{ id: "runtime-ok", status: "loaded", enabled: true }], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["runtime-ok"], + unregisteredMemoryEmbeddingProviders: [], + }); + const withAbsent = formatDetailedPluginHealth({ + plugins: [{ id: "runtime-ok", status: "loaded", enabled: true }], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["runtime-ok"], + }); + + expect(withEmpty).not.toContain("Configured memory provider not registered:"); + expect(withAbsent).not.toContain("Configured memory provider not registered:"); + }); }); diff --git a/src/status/status-plugin-health.ts b/src/status/status-plugin-health.ts index d7f35c4227df..2db9827e70c8 100644 --- a/src/status/status-plugin-health.ts +++ b/src/status/status-plugin-health.ts @@ -71,6 +71,15 @@ export type StatusPluginHealthSnapshot = { // is not in the runtime-loaded set. Absent on compact/hand-built snapshots, where // no drift line is rendered (back-compat). shouldRunPluginIds?: string[]; + // Configured memory embedding providers (memorySearch provider/fallback) that no + // loaded plugin registers, so semantic memory recall silently falls back to + // keyword/FTS-only. Detailed-status only; absent on compact/hand-built snapshots and + // whenever the live runtime registry is unavailable, so no line renders (back-compat). + // `source` mirrors MemoryEmbeddingStartupProviderSource ("provider" | "fallback"). + unregisteredMemoryEmbeddingProviders?: Array<{ + configuredId: string; + source: "provider" | "fallback"; + }>; }; /** Keeps the first record per key; later duplicates are dropped. */ @@ -307,6 +316,12 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot) const channelPluginFailures = (snapshot.channelPluginFailures ?? []).toSorted((left, right) => byLocale(left.channelId, right.channelId), ); + const unregisteredMemoryProviders = ( + snapshot.unregisteredMemoryEmbeddingProviders ?? [] + ).toSorted( + (left, right) => + byLocale(left.configuredId, right.configuredId) || byLocale(left.source, right.source), + ); const lines = [ formatCompactPluginHealthLine(snapshot), `Loaded: ${loaded.length}${loaded.length > 0 ? ` (${formatPluginList(loaded, 8)})` : ""}`, @@ -332,6 +347,18 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot) ); } + if (unregisteredMemoryProviders.length > 0) { + // A configured memory embedding provider that no loaded plugin registers: semantic + // memory recall silently falls back to keyword/FTS-only. Observer-only signal, distinct + // from plugin load/error state and not counted in the compact line. + const display = unregisteredMemoryProviders.map( + (entry) => `${entry.configuredId} (memorySearch.${entry.source})`, + ); + lines.push( + `Configured memory provider not registered: ${unregisteredMemoryProviders.length} (${formatPluginList(display, 8)})`, + ); + } + if (errors.length > 0) { lines.push( `Errors: ${errors.length}`,