mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 18:03:58 +00:00
fix(status): surface unregistered memory embedding providers (#97968)
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
export {
|
||||
collectConfiguredMemoryEmbeddingProviderIds,
|
||||
collectConfiguredMemoryEmbeddingStartupProviderOwners,
|
||||
collectRegisteredEmbeddingProviderIds,
|
||||
collectUnregisteredConfiguredMemoryEmbeddingProviders,
|
||||
resolveChannelPluginIds,
|
||||
resolveChannelPluginIdsFromRegistry,
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user