fix(status): surface unregistered memory embedding providers (#97968)

This commit is contained in:
Masato Hoshino
2026-07-01 16:46:49 +09:00
committed by GitHub
parent b63e06f68a
commit 0a9708ed0f
8 changed files with 262 additions and 13 deletions

View File

@@ -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<Pick<PluginRegistry, "embeddingProviders" | "memoryEmbeddingProviders">>;
log: Pick<GatewayPluginBootstrapLog, "warn">;
}): 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}`;

View File

@@ -16,6 +16,7 @@ export {
export {
collectConfiguredMemoryEmbeddingProviderIds,
collectConfiguredMemoryEmbeddingStartupProviderOwners,
collectRegisteredEmbeddingProviderIds,
collectUnregisteredConfiguredMemoryEmbeddingProviders,
resolveChannelPluginIds,
resolveChannelPluginIdsFromRegistry,

View File

@@ -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)),
);
});
});

View File

@@ -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<Pick<PluginRegistry, "embeddingProviders" | "memoryEmbeddingProviders">>,
): Set<string> {
return new Set(
[
...(registry.memoryEmbeddingProviders ?? []),
...(registry.embeddingProviders ?? []),
...listRegisteredEmbeddingProviders().map((entry) => ({ provider: entry.adapter })),
].map((entry) => entry.provider.id),
);
}
function addPluginConfigEntryIds(
target: Set<string>,
plugins: ReturnType<typeof normalizePluginsConfigForInstalledIndex>,

View File

@@ -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<typeof import("../plugins/gateway-startup-plugin-ids.js")>();
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();
});
});
});

View File

@@ -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<typeof getActiveRuntimePluginRegistry>;
}): Promise<Array<{ configuredId: string; source: "provider" | "fallback" }> | 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

View File

@@ -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:");
});
});

View File

@@ -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}`,