diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index ab205acac15d..ad7d8540b490 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -435,3 +435,293 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => { expect(startupInput.suppressPluginInfoLogs).toBe(false); }); }); + +describe("loadGatewayStartupPluginRuntime memory provider diagnostics", () => { + beforeEach(() => { + loadGatewayStartupPlugins.mockClear().mockReturnValue({ + pluginRegistry: { diagnostics: [], gatewayHandlers: {}, plugins: [] }, + gatewayMethods: ["ping"], + }); + }); + + it("warns after a full startup runtime load when configured memory embedding providers stay unregistered", async () => { + const log = createLog(); + const { loadGatewayStartupPluginRuntime } = await import("./server-startup-plugins.js"); + + await loadGatewayStartupPluginRuntime({ + cfg: { + agents: { + defaults: { + memorySearch: { + provider: "voyage", + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/workspace", + log, + baseMethods: ["ping"], + startupPluginIds: ["voyage"], + }); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('memorySearch.provider="voyage"'), + ); + }); + + it("does not warn during setup-runtime pre-bind loads", async () => { + const log = createLog(); + const { loadGatewayStartupPluginRuntime } = await import("./server-startup-plugins.js"); + + await loadGatewayStartupPluginRuntime({ + cfg: { + agents: { + defaults: { + memorySearch: { + provider: "voyage", + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/workspace", + log, + baseMethods: ["ping"], + startupPluginIds: ["telegram"], + preferSetupRuntimeForChannelPlugins: true, + }); + + expect(log.warn).not.toHaveBeenCalled(); + }); +}); + +describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { + function registry(providerIds: string[], options: { embeddingProviderIds?: string[] } = {}) { + return { + memoryEmbeddingProviders: providerIds.map((id) => ({ provider: { id } })), + embeddingProviders: (options.embeddingProviderIds ?? []).map((id) => ({ provider: { id } })), + } as never; + } + + it("warns when a configured memory embedding provider is not registered", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "openai" } } }, + } as OpenClawConfig, + pluginRegistry: registry([]), + log, + }); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.provider="openai"'); + }); + + it("does not warn when the configured memory embedding provider is registered", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "openai" } } }, + } as OpenClawConfig, + pluginRegistry: registry(["openai"]), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("warns when a configured memory embedding fallback is not registered", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "openai", fallback: "ollama" } } }, + } as OpenClawConfig, + pluginRegistry: registry(["openai"]), + log, + }); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.fallback="ollama"'); + }); + + it("does not warn when the configured memory embedding fallback is registered", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "openai", fallback: "ollama" } } }, + } as OpenClawConfig, + pluginRegistry: registry(["openai", "ollama"]), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("does not warn when a generic embedding provider can serve configured memory search", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "generic-embed" } } }, + } as OpenClawConfig, + pluginRegistry: registry([], { embeddingProviderIds: ["generic-embed"] }), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("does not warn for core generic memory embedding providers", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "openai-compatible" } } }, + } as OpenClawConfig, + pluginRegistry: registry([]), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("does not warn for custom providers backed by core generic embeddings", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "tenant-embeddings" } } }, + models: { + providers: { + "tenant-embeddings": { + api: "openai-responses", + baseUrl: "http://127.0.0.1:11434/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + pluginRegistry: registry([]), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("does not warn for memory embedding fallbacks when primary provider is fts-only", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "none", fallback: "openai" } } }, + } as OpenClawConfig, + pluginRegistry: registry([]), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("does not warn for memory embedding providers when the memory slot is disabled", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { defaults: { memorySearch: { provider: "openai", fallback: "ollama" } } }, + plugins: { slots: { memory: "none" } }, + } as OpenClawConfig, + pluginRegistry: registry([]), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + function customOllamaConfig(source: "provider" | "fallback" = "provider"): OpenClawConfig { + const memorySearch = + source === "provider" + ? { provider: "ollama-5080" } + : { provider: "openai", fallback: "ollama-5080" }; + return { + agents: { defaults: { memorySearch } }, + models: { + providers: { + "ollama-5080": { + api: "ollama", + baseUrl: "http://gpu-box.local:11435", + models: [], + }, + }, + }, + } as OpenClawConfig; + } + + it.each([ + ["provider", "memorySearch.provider"] as const, + ["fallback", "memorySearch.fallback"] as const, + ])( + "does not warn for custom %s entries whose api-owner plugin is registered", + async (source, _path) => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: customOllamaConfig(source), + pluginRegistry: registry(["openai", "ollama"]), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }, + ); + + it("warns for custom providers whose api-owner plugin is not registered", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: customOllamaConfig(), + pluginRegistry: registry([]), + log, + }); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.provider="ollama-5080"'); + }); + + it("warns for custom fallbacks whose api-owner plugin is not registered", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: customOllamaConfig("fallback"), + pluginRegistry: registry(["openai"]), + log, + }); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.fallback="ollama-5080"'); + }); + + it("does not warn for sentinel or disabled memory search providers", async () => { + const { warnUnregisteredConfiguredMemoryEmbeddingProviders } = + await import("./server-startup-plugins.js"); + const log = createLog(); + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: { + agents: { + defaults: { memorySearch: { provider: "local", fallback: "auto" } }, + list: [ + { + id: "muted", + memorySearch: { enabled: false, provider: "openai", fallback: "ollama" }, + }, + ], + }, + } as OpenClawConfig, + pluginRegistry: registry([]), + log, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 60193b04c477..65bc93ff7ca3 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -4,9 +4,11 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { initSubagentRegistry } from "../agents/subagent-registry.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.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 { loadPluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; -import type { PluginRegistryParams } from "../plugins/registry-types.js"; +import type { PluginRegistry, PluginRegistryParams } from "../plugins/registry-types.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { listCoreGatewayMethodNames } from "./methods/core-descriptors.js"; @@ -169,6 +171,9 @@ export async function prepareGatewayPluginBootstrap(params: { setActivePluginRegistry(pluginRegistry); } + const runtimePluginsLoaded = + !params.minimalTestGateway && shouldLoadRuntimePlugins && !shouldLoadSetupRuntimePlugins; + return { gatewayPluginConfigAtStart: gatewayPluginConfig, defaultWorkspaceDir, @@ -178,11 +183,39 @@ export async function prepareGatewayPluginBootstrap(params: { baseMethods, pluginRegistry, baseGatewayMethods, - runtimePluginsLoaded: - !params.minimalTestGateway && shouldLoadRuntimePlugins && !shouldLoadSetupRuntimePlugins, + runtimePluginsLoaded, }; } +/** + * Warn when `agents.*.memorySearch.provider` selects a memory embedding provider + * that no loaded plugin registered. Without the owning plugin, `active-memory` + * cannot embed and silently falls back to keyword/FTS-only recall. + */ +export function warnUnregisteredConfiguredMemoryEmbeddingProviders(params: { + config: OpenClawConfig; + 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, + }); + for (const provider of unregistered) { + const path = `memorySearch.${provider.source}`; + params.log.warn( + `${path}="${provider.configuredId}" is configured, but no loaded plugin registered a memory embedding provider that can serve "${provider.configuredId}". Semantic memory recall will fall back to keyword/FTS-only search. Ensure the plugin that provides "${provider.configuredId}" is installed and enabled.`, + ); + } +} + /** Loads startup plugin runtimes through the deferred bootstrap boundary. */ export async function loadGatewayStartupPluginRuntime(params: { cfg: OpenClawConfig; @@ -201,7 +234,7 @@ export async function loadGatewayStartupPluginRuntime(params: { // Keep server-plugin-bootstrap behind one lazy boundary; startup config tests can exercise // planning without importing plugin package runtimes. const { loadGatewayStartupPlugins } = await import("./server-plugin-bootstrap.js"); - return loadGatewayStartupPlugins({ + const loaded = loadGatewayStartupPlugins({ cfg: params.cfg, activationSourceConfig: params.activationSourceConfig, workspaceDir: params.workspaceDir, @@ -217,4 +250,15 @@ export async function loadGatewayStartupPluginRuntime(params: { suppressPluginInfoLogs: params.suppressPluginInfoLogs, startupTrace: params.startupTrace, }); + if (params.preferSetupRuntimeForChannelPlugins !== true) { + // Surface configured memory embedding providers after the full startup + // runtime load; setup-runtime pre-bind loads intentionally register only + // early channel hooks and would produce false missing-provider warnings. + warnUnregisteredConfiguredMemoryEmbeddingProviders({ + config: params.cfg, + pluginRegistry: loaded.pluginRegistry, + log: params.log, + }); + } + return loaded; } diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index ac10595da064..52c00e35c238 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -179,6 +179,29 @@ function createManifestRegistryFixture(): PluginManifestRegistry { realtimeVoiceProviders: ["openai"], imageGenerationProviders: ["openai"], videoGenerationProviders: ["openai"], + memoryEmbeddingProviders: ["openai"], + }, + }, + { + id: "ollama", + channels: [], + origin: "bundled", + enabledByDefault: true, + providers: ["ollama"], + cliBackends: [], + contracts: { + memoryEmbeddingProviders: ["ollama"], + }, + }, + { + id: "generic-embedding", + channels: [], + origin: "bundled", + enabledByDefault: true, + providers: [], + cliBackends: [], + contracts: { + embeddingProviders: ["generic-embed"], }, }, { @@ -928,6 +951,261 @@ describe("resolveGatewayStartupPluginIds", () => { } as OpenClawConfig, ["browser", "memory-core"], ], + [ + "includes the owning plugin for a configured memory embedding provider at startup", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "openai" }, + }, + }, + } as OpenClawConfig, + ["browser", "openai", "memory-core"], + ], + [ + "includes the owning plugin for a configured memory embedding fallback at startup", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "ollama", fallback: "openai" }, + }, + }, + } as OpenClawConfig, + ["browser", "openai", "ollama", "memory-core"], + ], + [ + "includes the owning plugin for a per-agent memory embedding provider at startup", + { + channels: {}, + agents: { + list: [{ id: "researcher", memorySearch: { provider: "openai" } }], + }, + } as OpenClawConfig, + ["browser", "openai", "memory-core"], + ], + [ + "includes the api-owner plugin for a custom models.providers memory embedding provider at startup", + { + channels: {}, + agents: { + defaults: { + // Custom id resolves to its `api` owner ("ollama") for the embedding + // adapter, so the owning plugin must load at startup. + memorySearch: { provider: "ollama-5080" }, + }, + }, + models: { + providers: { + "ollama-5080": { + api: "ollama", + baseUrl: "http://gpu-box.local:11435", + models: [], + }, + }, + }, + } as OpenClawConfig, + ["browser", "ollama", "memory-core"], + ], + [ + "includes the api-owner plugin for a custom models.providers memory embedding fallback at startup", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "openai", fallback: "ollama-5080" }, + }, + }, + models: { + providers: { + "ollama-5080": { + api: "ollama", + baseUrl: "http://gpu-box.local:11435", + models: [], + }, + }, + }, + } as OpenClawConfig, + ["browser", "openai", "ollama", "memory-core"], + ], + [ + "includes generic embedding provider owners for configured memory search at startup", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "generic-embed" }, + }, + }, + } as OpenClawConfig, + ["browser", "generic-embedding", "memory-core"], + ], + [ + "does not load plugin owners for core generic memory embedding providers", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "openai-compatible" }, + }, + }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "does not load plugin owners for custom providers backed by core generic embeddings", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "tenant-embeddings" }, + }, + }, + models: { + providers: { + "tenant-embeddings": { + api: "openai-responses", + baseUrl: "http://127.0.0.1:11434/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "does not load memory embedding provider owners when the memory slot is disabled", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "openai", fallback: "ollama" }, + }, + }, + plugins: { + slots: { memory: "none" }, + }, + } as OpenClawConfig, + ["browser"], + ], + [ + "ignores memory embedding fallbacks when primary provider is fts-only", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "none", fallback: "openai" }, + }, + }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "ignores sentinel memory embedding providers that no plugin owns", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "local", fallback: "auto" }, + }, + }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "skips memory embedding providers from disabled memory search blocks", + { + channels: {}, + agents: { + defaults: { + memorySearch: { enabled: false, provider: "openai", fallback: "ollama" }, + }, + }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "honors explicit plugin disablement for configured memory embedding providers", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "openai" }, + }, + }, + plugins: { entries: { openai: { enabled: false } } }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "honors denied plugins for configured memory embedding providers", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "openai" }, + }, + }, + plugins: { deny: ["openai"] }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "skips a per-agent memory embedding provider when memory search is disabled by inherited defaults", + { + channels: {}, + agents: { + defaults: { + memorySearch: { enabled: false }, + }, + list: [{ id: "researcher", memorySearch: { provider: "openai", fallback: "ollama" } }], + }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "includes the inherited default provider when a per-agent override re-enables memory search", + { + channels: {}, + agents: { + defaults: { + memorySearch: { enabled: false, provider: "openai", fallback: "ollama" }, + }, + list: [{ id: "researcher", memorySearch: { enabled: true } }], + }, + } as OpenClawConfig, + ["browser", "openai", "ollama", "memory-core"], + ], + [ + "includes default memory embedding providers for unlisted agents even when listed agents override memory search", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "openai" }, + }, + list: [ + { id: "muted", memorySearch: { enabled: false } }, + { id: "researcher", memorySearch: { provider: "ollama" } }, + ], + }, + } as OpenClawConfig, + ["browser", "openai", "ollama", "memory-core"], + ], + [ + "includes default memory embedding providers for listed agents that inherit defaults", + { + channels: {}, + agents: { + defaults: { + memorySearch: { provider: "openai" }, + }, + list: [{ id: "researcher" }], + }, + } as OpenClawConfig, + ["browser", "openai", "memory-core"], + ], [ "includes explicitly selected external web search providers at startup", { @@ -1544,6 +1822,32 @@ describe("resolveGatewayStartupPluginIds", () => { ).toEqual(["amazon-bedrock", "browser"]); }); + it("keeps configured memory embedding providers in restrictive startup metadata scopes", () => { + const registry = createManifestRegistryFixture(); + const index = createInstalledPluginIndexFixture(registry); + + expect( + resolveGatewayStartupMetadataPluginIds({ + config: { + agents: { + defaults: { + memorySearch: { provider: "openai", fallback: "ollama" }, + }, + }, + channels: {}, + plugins: { + allow: ["browser", "memory-core"], + slots: { + memory: "memory-core", + }, + }, + } as OpenClawConfig, + env: createPluginPlanningTestEnv(), + index, + }), + ).toEqual(["browser", "memory-core", "ollama", "openai"]); + }); + it("uses installed-index model support for restrictive startup shorthand model scopes", () => { const registry = createManifestRegistryFixture(); const index = createInstalledPluginIndexFixture(registry); diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index 001684162320..98e7d0d35297 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -14,6 +14,9 @@ export { } from "./channel-presence-policy.js"; export { + collectConfiguredMemoryEmbeddingProviderIds, + collectConfiguredMemoryEmbeddingStartupProviderOwners, + collectUnregisteredConfiguredMemoryEmbeddingProviders, resolveChannelPluginIds, resolveChannelPluginIdsFromRegistry, resolveConfiguredDeferredChannelPluginIds, diff --git a/src/plugins/embedding-provider-config.ts b/src/plugins/embedding-provider-config.ts new file mode 100644 index 000000000000..e95845d826bb --- /dev/null +++ b/src/plugins/embedding-provider-config.ts @@ -0,0 +1,60 @@ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +type ConfiguredModelProvider = NonNullable< + NonNullable["providers"] +>[string]; +const OPENAI_COMPATIBLE_EMBEDDING_PROVIDER_ID = "openai-compatible"; +const OPENAI_COMPATIBLE_MODEL_APIS = new Set(["openai-completions", "openai-responses"]); + +function resolveConfiguredProviderConfig( + providerId: string, + cfg?: OpenClawConfig, +): ConfiguredModelProvider | undefined { + const providers = cfg?.models?.providers; + if (!providers) { + return undefined; + } + const normalized = normalizeProviderId(providerId); + return ( + providers[providerId] ?? + Object.entries(providers).find( + ([candidateId]) => normalizeProviderId(candidateId) === normalized, + )?.[1] + ); +} + +/** Reads a configured provider's backing API id when runtime lookup should follow an alias. */ +export function readConfiguredProviderApiId(params: { + providerId: string; + cfg?: OpenClawConfig; + resolveApiProviderId?: (normalizedApiId: string) => string | undefined; + resolveMissingApiProviderId?: (providerConfig: ConfiguredModelProvider) => string | undefined; +}): string | undefined { + const providerConfig = resolveConfiguredProviderConfig(params.providerId, params.cfg); + if (!providerConfig) { + return undefined; + } + const normalized = normalizeProviderId(params.providerId); + const api = providerConfig.api?.trim(); + const resolvedProviderId = api + ? (params.resolveApiProviderId?.(normalizeProviderId(api)) ?? normalizeProviderId(api)) + : params.resolveMissingApiProviderId?.(providerConfig); + return resolvedProviderId && resolvedProviderId !== normalized ? resolvedProviderId : undefined; +} + +export function resolveConfiguredGenericEmbeddingProviderId( + providerId: string, + cfg?: OpenClawConfig, +): string | undefined { + return readConfiguredProviderApiId({ + providerId, + cfg, + resolveApiProviderId: (normalizedApiId) => + OPENAI_COMPATIBLE_MODEL_APIS.has(normalizedApiId) + ? OPENAI_COMPATIBLE_EMBEDDING_PROVIDER_ID + : normalizedApiId, + resolveMissingApiProviderId: (providerConfig) => + providerConfig.baseUrl?.trim() ? OPENAI_COMPATIBLE_EMBEDDING_PROVIDER_ID : undefined, + }); +} diff --git a/src/plugins/embedding-provider-runtime-shared.ts b/src/plugins/embedding-provider-runtime-shared.ts index 29d5cbea0959..d46508b2545d 100644 --- a/src/plugins/embedding-provider-runtime-shared.ts +++ b/src/plugins/embedding-provider-runtime-shared.ts @@ -10,45 +10,6 @@ type EmbeddingProviderCapabilityKey = "embeddingProviders" | "memoryEmbeddingPro type RegisteredAdapterEntry = { adapter: TAdapter; }; -type ConfiguredModelProvider = NonNullable< - NonNullable["providers"] ->[string]; - -function resolveConfiguredProviderConfig( - providerId: string, - cfg?: OpenClawConfig, -): ConfiguredModelProvider | undefined { - const providers = cfg?.models?.providers; - if (!providers) { - return undefined; - } - const normalized = normalizeProviderId(providerId); - return ( - providers[providerId] ?? - Object.entries(providers).find( - ([candidateId]) => normalizeProviderId(candidateId) === normalized, - )?.[1] - ); -} - -/** Reads a configured provider's backing API id when runtime lookup should follow an alias. */ -export function readConfiguredProviderApiId(params: { - providerId: string; - cfg?: OpenClawConfig; - resolveApiProviderId?: (normalizedApiId: string) => string | undefined; - resolveMissingApiProviderId?: (providerConfig: ConfiguredModelProvider) => string | undefined; -}): string | undefined { - const providerConfig = resolveConfiguredProviderConfig(params.providerId, params.cfg); - if (!providerConfig) { - return undefined; - } - const normalized = normalizeProviderId(params.providerId); - const api = providerConfig.api?.trim(); - const resolvedProviderId = api - ? (params.resolveApiProviderId?.(normalizeProviderId(api)) ?? normalizeProviderId(api)) - : params.resolveMissingApiProviderId?.(providerConfig); - return resolvedProviderId && resolvedProviderId !== normalized ? resolvedProviderId : undefined; -} /** Builds lookup ids for embedding providers, including configured API aliases. */ export function resolveRuntimeEmbeddingProviderLookupIds(params: { diff --git a/src/plugins/embedding-provider-runtime.ts b/src/plugins/embedding-provider-runtime.ts index 59c9bf2ebc4d..2af97834796d 100644 --- a/src/plugins/embedding-provider-runtime.ts +++ b/src/plugins/embedding-provider-runtime.ts @@ -1,9 +1,9 @@ /** Runtime resolver for plugin-contributed embedding providers. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveConfiguredGenericEmbeddingProviderId } from "./embedding-provider-config.js"; import { getRuntimeEmbeddingProviderAdapter, listRuntimeEmbeddingProviderAdapters, - readConfiguredProviderApiId, resolveRuntimeEmbeddingProviderLookupIds, } from "./embedding-provider-runtime-shared.js"; import { @@ -12,9 +12,6 @@ import { type EmbeddingProviderAdapter, } from "./embedding-providers.js"; -const OPENAI_COMPATIBLE_EMBEDDING_PROVIDER_ID = "openai-compatible"; -const OPENAI_COMPATIBLE_MODEL_APIS = new Set(["openai-completions", "openai-responses"]); - export { listRegisteredEmbeddingProviders }; /** Lists embedding provider adapters registered directly with the process registry. */ @@ -31,20 +28,11 @@ export function listEmbeddingProviders(cfg?: OpenClawConfig): EmbeddingProviderA }); } -function resolveConfiguredEmbeddingProviderId( +export function resolveConfiguredEmbeddingProviderId( providerId: string, cfg?: OpenClawConfig, ): string | undefined { - return readConfiguredProviderApiId({ - providerId, - cfg, - resolveApiProviderId: (normalizedApiId) => - OPENAI_COMPATIBLE_MODEL_APIS.has(normalizedApiId) - ? OPENAI_COMPATIBLE_EMBEDDING_PROVIDER_ID - : normalizedApiId, - resolveMissingApiProviderId: (providerConfig) => - providerConfig.baseUrl?.trim() ? OPENAI_COMPATIBLE_EMBEDDING_PROVIDER_ID : undefined, - }); + return resolveConfiguredGenericEmbeddingProviderId(providerId, cfg); } function resolveEmbeddingProviderLookupIds(id: string, cfg?: OpenClawConfig): string[] { diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index a99736fdd347..cb043244385f 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -1,7 +1,10 @@ /** Resolves plugin ids that should load during Gateway startup. */ import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs"; import { buildModelCatalogMergeKey } from "@openclaw/model-catalog-core/model-catalog-refs"; -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { + findNormalizedProviderValue, + normalizeProviderId, +} from "@openclaw/model-catalog-core/provider-id"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; @@ -23,6 +26,7 @@ import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { normalizePluginsConfigWithResolver } from "./config-normalization-shared.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; +import { resolveConfiguredGenericEmbeddingProviderId } from "./embedding-provider-config.js"; import { collectConfiguredSpeechProviderIds, normalizeConfiguredSpeechProviderIdForStartup, @@ -480,6 +484,211 @@ function collectConfiguredVoiceProviderIds(config: OpenClawConfig): ConfiguredVo }; } +// Explicit memory provider startup only pulls plugin-owned remote/custom +// providers into Gateway boot. Missing/"auto" stays lazy, "local" is covered by +// the selected memory slot, and "none" disables provider-backed embeddings. +const MEMORY_EMBEDDING_PROVIDER_STARTUP_SKIP_IDS: ReadonlySet = new Set([ + "auto", + "local", + "none", +]); + +function normalizeMemoryEmbeddingProviderIdValue(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = normalizeOptionalLowercaseString(value); + return normalized || undefined; +} + +function normalizeExplicitMemoryEmbeddingProviderId(value: unknown): string | undefined { + const normalized = normalizeMemoryEmbeddingProviderIdValue(value); + return normalized && !MEMORY_EMBEDDING_PROVIDER_STARTUP_SKIP_IDS.has(normalized) + ? normalized + : undefined; +} + +function readMemorySearchEnabled( + memorySearch: Record | undefined, +): boolean | undefined { + const enabled = memorySearch?.enabled; + return typeof enabled === "boolean" ? enabled : undefined; +} + +function isMemorySlotExplicitlyDisabled(config: OpenClawConfig): boolean { + return normalizeOptionalLowercaseString(config.plugins?.slots?.memory) === "none"; +} + +export type MemoryEmbeddingStartupProviderSource = "provider" | "fallback"; + +export type ConfiguredMemoryEmbeddingStartupProviderOwner = { + /** Raw memory-search provider id as configured (normalized). */ + configuredId: string; + /** + * Adapter ids a plugin can own for this provider: the configured id plus its + * `models.providers..api` owner when a custom provider maps to one. + */ + ownerIds: ReadonlySet; + source: MemoryEmbeddingStartupProviderSource; +}; + +/** + * Resolve a configured memory embedding provider id to the adapter id(s) a + * plugin manifest contract or runtime registry can own. Mirrors runtime + * `getConfiguredMemoryEmbeddingProvider`: the raw id maps to a direct adapter, + * and a custom `models.providers.` entry additionally maps to its `api` + * owner adapter (`provider: "ollama-5080"` with `api: "ollama"` -> "ollama"). + * Both candidates are returned so matching covers the direct adapter and the + * API owner without the runtime adapter registry. + */ +function resolveMemoryEmbeddingProviderOwnerIds( + providerId: string, + config: OpenClawConfig, +): string[] { + const ownerIds = [providerId]; + const genericOwnerId = normalizeOptionalLowercaseString( + resolveConfiguredGenericEmbeddingProviderId(providerId, config), + ); + if (genericOwnerId && genericOwnerId !== providerId) { + ownerIds.push(genericOwnerId); + } + const ownerApi = normalizeOptionalLowercaseString( + findNormalizedProviderValue(config.models?.providers, providerId)?.api, + ); + if (ownerApi && ownerApi !== providerId) { + ownerIds.push(ownerApi); + } + return ownerIds; +} + +function resolveEffectiveMemoryEmbeddingProviderEntries( + defaults: Record | undefined, + override: Record | undefined, +): Array<{ + configuredId: string; + source: MemoryEmbeddingStartupProviderSource; +}> { + const enabled = readMemorySearchEnabled(override) ?? readMemorySearchEnabled(defaults) ?? true; + if (!enabled) { + return []; + } + const rawProvider = normalizeMemoryEmbeddingProviderIdValue( + override?.provider ?? defaults?.provider, + ); + const effectiveProvider = rawProvider === "auto" || !rawProvider ? "openai" : rawProvider; + if (effectiveProvider === "none") { + return []; + } + const entries: Array<{ + configuredId: string; + source: MemoryEmbeddingStartupProviderSource; + }> = []; + const provider = + rawProvider && !MEMORY_EMBEDDING_PROVIDER_STARTUP_SKIP_IDS.has(rawProvider) + ? rawProvider + : undefined; + if (provider) { + entries.push({ configuredId: provider, source: "provider" }); + } + const fallback = normalizeExplicitMemoryEmbeddingProviderId( + override?.fallback ?? defaults?.fallback ?? "none", + ); + if (fallback && fallback !== effectiveProvider) { + entries.push({ configuredId: fallback, source: "fallback" }); + } + return entries; +} + +/** + * Collect explicit memory embedding provider owners required by startup. The + * resolver mirrors runtime memory-search inheritance for enablement, primary + * provider, and fallback provider, then maps custom `models.providers` ids to + * their API-owner adapter ids. + */ +export function collectConfiguredMemoryEmbeddingStartupProviderOwners( + config: OpenClawConfig, +): ConfiguredMemoryEmbeddingStartupProviderOwner[] { + if (isMemorySlotExplicitlyDisabled(config)) { + return []; + } + const byConfiguredIdAndSource = new Map(); + const defaultsBlock = config.agents?.defaults?.memorySearch; + const defaults = isRecord(defaultsBlock) ? defaultsBlock : undefined; + const addEffectiveProviders = (override: Record | undefined) => { + for (const { configuredId, source } of resolveEffectiveMemoryEmbeddingProviderEntries( + defaults, + override, + )) { + const key = `${source}\0${configuredId}`; + if (byConfiguredIdAndSource.has(key)) { + continue; + } + byConfiguredIdAndSource.set(key, { + configuredId, + ownerIds: new Set(resolveMemoryEmbeddingProviderOwnerIds(configuredId, config)), + source, + }); + } + }; + addEffectiveProviders(undefined); + const agents = config.agents?.list; + const agentEntries = Array.isArray(agents) ? agents.filter(isRecord) : []; + if (agentEntries.length === 0) { + return [...byConfiguredIdAndSource.values()]; + } + for (const agent of agentEntries) { + addEffectiveProviders(isRecord(agent.memorySearch) ? agent.memorySearch : undefined); + } + return [...byConfiguredIdAndSource.values()]; +} + +/** + * Collect configured memory embedding provider ids that map to a plugin-owned + * memory embedding provider contract, including the resolved `api` owner for + * custom `models.providers` ids so the owning plugin loads at startup. + */ +export function collectConfiguredMemoryEmbeddingProviderIds( + config: OpenClawConfig, +): ReadonlySet { + const providerIds = new Set(); + for (const provider of collectConfiguredMemoryEmbeddingStartupProviderOwners(config)) { + for (const ownerId of provider.ownerIds) { + providerIds.add(ownerId); + } + } + return providerIds; +} + +/** + * Report configured memory embedding providers that no loaded plugin can serve. + * A provider is unregistered only when none of its resolved adapter ids (the + * configured id and its `models.providers..api` owner) was registered, so + * custom providers warn when their API-owner plugin is missing but stay quiet + * once that plugin loads. + */ +export function collectUnregisteredConfiguredMemoryEmbeddingProviders(params: { + config: OpenClawConfig; + registeredProviderIds: ReadonlySet; +}): Array<{ configuredId: string; source: MemoryEmbeddingStartupProviderSource }> { + const configured = collectConfiguredMemoryEmbeddingStartupProviderOwners(params.config); + if (configured.length === 0) { + return []; + } + const registered = new Set( + [...params.registeredProviderIds] + .map((id) => normalizeOptionalLowercaseString(id)) + .filter((id): id is string => Boolean(id)), + ); + return configured + .filter((provider) => ![...provider.ownerIds].some((ownerId) => registered.has(ownerId))) + .map((provider) => ({ configuredId: provider.configuredId, source: provider.source })) + .toSorted( + (left, right) => + left.configuredId.localeCompare(right.configuredId) || + left.source.localeCompare(right.source), + ); +} + function addPluginConfigEntryIds( target: Set, plugins: ReturnType, @@ -589,6 +798,7 @@ function collectConfiguredProviderIds(config: OpenClawConfig): string[] { ...configuredVoiceProviderIds.speechProviders, ...configuredVoiceProviderIds.realtimeTranscriptionProviders, ...configuredVoiceProviderIds.realtimeVoiceProviders, + ...collectConfiguredMemoryEmbeddingProviderIds(config), ]); } @@ -1015,6 +1225,23 @@ function manifestOwnsConfiguredVoiceProvider(params: { return false; } +function manifestOwnsConfiguredMemoryEmbeddingProvider(params: { + manifest: PluginManifestRecord | undefined; + configuredMemoryEmbeddingProviderIds: ReadonlySet; +}): boolean { + if (params.configuredMemoryEmbeddingProviderIds.size === 0) { + return false; + } + const embeddingProviderIds = [ + ...(params.manifest?.contracts?.memoryEmbeddingProviders ?? []), + ...(params.manifest?.contracts?.embeddingProviders ?? []), + ]; + return embeddingProviderIds.some((providerId) => { + const normalized = normalizeOptionalLowercaseString(providerId); + return normalized ? params.configuredMemoryEmbeddingProviderIds.has(normalized) : false; + }); +} + function canStartConfiguredGenerationProviderPlugin(params: { plugin: InstalledPluginIndexRecord; manifest: PluginManifestRecord | undefined; @@ -1113,6 +1340,55 @@ function canStartConfiguredVoiceProviderPlugin(params: { ); } +function canStartConfiguredMemoryEmbeddingProviderPlugin(params: { + plugin: InstalledPluginIndexRecord; + manifest: PluginManifestRecord | undefined; + config: OpenClawConfig; + pluginsConfig: ReturnType; + activationSource: { + plugins: ReturnType; + rootConfig?: OpenClawConfig; + }; + configuredMemoryEmbeddingProviderIds: ReadonlySet; + platform?: NodeJS.Platform; +}): boolean { + if ( + !manifestOwnsConfiguredMemoryEmbeddingProvider({ + manifest: params.manifest, + configuredMemoryEmbeddingProviderIds: params.configuredMemoryEmbeddingProviderIds, + }) + ) { + return false; + } + if (!params.pluginsConfig.enabled || !params.activationSource.plugins.enabled) { + return false; + } + if ( + params.pluginsConfig.deny.includes(params.plugin.pluginId) || + params.activationSource.plugins.deny.includes(params.plugin.pluginId) + ) { + return false; + } + if ( + params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false || + params.activationSource.plugins.entries[params.plugin.pluginId]?.enabled === false + ) { + return false; + } + const activationState = resolveEffectivePluginActivationState({ + id: params.plugin.pluginId, + origin: params.plugin.origin, + config: params.pluginsConfig, + rootConfig: params.config, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform), + activationSource: params.activationSource, + }); + return ( + activationState.enabled && + (params.plugin.origin === "bundled" || activationState.explicitlyEnabled) + ); +} + function canStartConfiguredModelProviderPlugin(params: { plugin: InstalledPluginIndexRecord; manifest: PluginManifestRecord | undefined; @@ -1597,6 +1873,8 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { const configuredGenerationProviderIds = collectConfiguredGenerationProviderIds(activationSourceConfig); const configuredVoiceProviderIds = collectConfiguredVoiceProviderIds(activationSourceConfig); + const configuredMemoryEmbeddingProviderIds = + collectConfiguredMemoryEmbeddingProviderIds(activationSourceConfig); const normalizePluginId = createPluginRegistryIdNormalizer(params.index, { manifestRegistry: params.manifestRegistry, }); @@ -1731,6 +2009,20 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { pluginIds.push(plugin.pluginId); continue; } + if ( + canStartConfiguredMemoryEmbeddingProviderPlugin({ + plugin, + manifest, + config: params.config, + pluginsConfig, + activationSource, + configuredMemoryEmbeddingProviderIds, + platform: params.platform, + }) + ) { + pluginIds.push(plugin.pluginId); + continue; + } if ( canStartExplicitHookPlugin({ plugin, diff --git a/src/plugins/memory-embedding-provider-runtime.ts b/src/plugins/memory-embedding-provider-runtime.ts index f7ec1f692a1e..6744231940f0 100644 --- a/src/plugins/memory-embedding-provider-runtime.ts +++ b/src/plugins/memory-embedding-provider-runtime.ts @@ -1,9 +1,9 @@ // Runtime bridge for plugin-provided memory embedding providers. import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { readConfiguredProviderApiId } from "./embedding-provider-config.js"; import { getRuntimeEmbeddingProviderAdapter, listRuntimeEmbeddingProviderAdapters, - readConfiguredProviderApiId, resolveRuntimeEmbeddingProviderLookupIds, } from "./embedding-provider-runtime-shared.js"; import {