diff --git a/extensions/memory-core/src/memory/embeddings.ts b/extensions/memory-core/src/memory/embeddings.ts index 6740057dde8e..636a7f1a0c90 100644 --- a/extensions/memory-core/src/memory/embeddings.ts +++ b/extensions/memory-core/src/memory/embeddings.ts @@ -158,6 +158,17 @@ export function resolveEmbeddingProviderAdapterId( } } +export function resolveEmbeddingProviderAdapterTransport( + providerId: string, + config?: MemoryEmbeddingProviderCreateOptions["config"], +): MemoryEmbeddingProviderAdapter["transport"] { + try { + return getAdapter(providerId, config).transport; + } catch { + return undefined; + } +} + async function createWithAdapter( adapter: MemoryEmbeddingProviderAdapter, options: CreateEmbeddingProviderOptions, diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index f144043b50be..038f1f6635c2 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -68,6 +68,8 @@ vi.mock("./embeddings.js", () => { }; }, ) => config?.models?.providers?.[providerId]?.api ?? providerId, + resolveEmbeddingProviderAdapterTransport: (providerId: string) => + providerId === "local" ? "local" : "remote", createEmbeddingProvider: async (options: { provider?: string; model?: string; diff --git a/extensions/memory-core/src/memory/manager-sync-yield.test.ts b/extensions/memory-core/src/memory/manager-sync-yield.test.ts index 867b01a6db02..14ac277783b0 100644 --- a/extensions/memory-core/src/memory/manager-sync-yield.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-yield.test.ts @@ -40,6 +40,8 @@ vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => { vi.mock("./embeddings.js", () => ({ resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, + resolveEmbeddingProviderAdapterTransport: (providerId: string) => + providerId === "local" ? "local" : "remote", createEmbeddingProvider: vi.fn(), })); diff --git a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts index 550e93884c8b..add000afb38f 100644 --- a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts +++ b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts @@ -21,6 +21,8 @@ const createEmbeddingProviderMock = vi.hoisted(() => vi.mock("./embeddings.js", () => ({ createEmbeddingProvider: createEmbeddingProviderMock, resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, + resolveEmbeddingProviderAdapterTransport: (providerId: string) => + providerId === "local" ? "local" : "remote", resolveEmbeddingProviderFallbackModel: () => "fts-only", })); diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index f0dde8571dc4..60eedf7fff2e 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -22,9 +22,11 @@ import { type MemorySource, type MemorySyncProgressUpdate, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createEmbeddingProvider, + resolveEmbeddingProviderAdapterTransport, type EmbeddingProvider, type EmbeddingProviderId, type EmbeddingProviderRequest, @@ -71,6 +73,11 @@ const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol.for("openclaw.memoryIndexManagerCa export const EMBEDDING_PROBE_CACHE_TTL_MS = 30_000; const log = createSubsystemLogger("memory"); type MemoryIndexManagerPurpose = "default" | "status" | "cli"; +type MemoryEmbeddingProviderRequirement = { + mode: "fts-only" | "optional" | "required"; + provider: string; + configuredProvider?: string; +}; const { cache: INDEX_CACHE, pending: INDEX_CACHE_PENDING } = resolveSingletonManagedCache(MEMORY_INDEX_MANAGER_CACHE_KEY); @@ -103,7 +110,18 @@ export async function closeMemoryIndexManagersForAgent(params: { return; } const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); - const key = `${params.agentId}:${workspaceDir}:${JSON.stringify(settings)}:default`; + const providerRequirement = resolveMemoryEmbeddingProviderRequirement({ + cfg: params.cfg, + agentId: params.agentId, + settings, + }); + const key = resolveMemoryIndexManagerCacheKey({ + agentId: params.agentId, + workspaceDir, + settings, + providerRequirement, + purpose: "default", + }); const pending = INDEX_CACHE_PENDING.get(key); if (pending) { await Promise.allSettled([pending]); @@ -138,6 +156,56 @@ function resolveEffectiveMemorySearchSettings( }; } +function resolveConfiguredMemoryEmbeddingProvider(params: { + cfg: OpenClawConfig; + agentId: string; +}): string | undefined { + const normalizedAgentId = normalizeAgentId(params.agentId); + const agentEntry = params.cfg.agents?.list?.find( + (entry) => entry && normalizeAgentId(entry.id) === normalizedAgentId, + ); + return agentEntry?.memorySearch?.provider ?? params.cfg.agents?.defaults?.memorySearch?.provider; +} + +function resolveMemoryEmbeddingProviderRequirement(params: { + cfg: OpenClawConfig; + agentId: string; + settings: ResolvedMemorySearchConfig; +}): MemoryEmbeddingProviderRequirement { + const configuredProvider = resolveConfiguredMemoryEmbeddingProvider(params)?.trim(); + if (params.settings.provider === "none" || configuredProvider === "none") { + return { mode: "fts-only", provider: params.settings.provider }; + } + const adapterTransport = resolveEmbeddingProviderAdapterTransport( + params.settings.provider, + params.cfg, + ); + if (!configuredProvider || configuredProvider === "auto" || adapterTransport === "local") { + return { mode: "optional", provider: params.settings.provider }; + } + return { + mode: "required", + provider: params.settings.provider, + configuredProvider, + }; +} + +function resolveMemoryIndexManagerCacheKey(params: { + agentId: string; + workspaceDir: string; + settings: ResolvedMemorySearchConfig; + providerRequirement: MemoryEmbeddingProviderRequirement; + purpose: MemoryIndexManagerPurpose; +}): string { + return [ + params.agentId, + params.workspaceDir, + JSON.stringify(params.settings), + JSON.stringify(params.providerRequirement), + params.purpose, + ].join(":"); +} + export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements MemorySearchManager { private readonly cacheKey: string; private readonly purpose: MemoryIndexManagerPurpose; @@ -145,6 +213,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem protected readonly agentId: string; protected readonly workspaceDir: string; protected readonly settings: ResolvedMemorySearchConfig; + private readonly providerRequirement: MemoryEmbeddingProviderRequirement; protected override provider: EmbeddingProvider | null; private readonly requestedProvider: EmbeddingProviderRequest; private providerInitPromise: Promise | null = null; @@ -237,7 +306,18 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const purpose = params.purpose === "status" || params.purpose === "cli" ? params.purpose : "default"; - const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}:${purpose}`; + const providerRequirement = resolveMemoryEmbeddingProviderRequirement({ + cfg, + agentId, + settings, + }); + const key = resolveMemoryIndexManagerCacheKey({ + agentId, + workspaceDir, + settings, + providerRequirement, + purpose, + }); const transient = purpose === "status" || purpose === "cli"; return await getOrCreateManagedCacheEntry({ cache: INDEX_CACHE, @@ -251,6 +331,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem agentId, workspaceDir, settings, + providerRequirement, purpose: params.purpose, }), }); @@ -262,6 +343,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem agentId: string; workspaceDir: string; settings: ResolvedMemorySearchConfig; + providerRequirement: MemoryEmbeddingProviderRequirement; providerResult?: EmbeddingProviderResult; purpose?: MemoryIndexManagerPurpose; }) { @@ -274,6 +356,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem this.agentId = params.agentId; this.workspaceDir = params.workspaceDir; this.settings = effectiveSettings; + this.providerRequirement = params.providerRequirement; this.provider = null; this.requestedProvider = effectiveSettings.provider; this.providerLifecycle = createPendingMemoryProviderLifecycle(this.requestedProvider); @@ -412,7 +495,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem protected isRequiredProviderUnavailable(): boolean { return ( - this.settings.providerRequirement.mode === "required" && + this.providerRequirement.mode === "required" && !this.provider && this.providerLifecycle.mode === "fts-only" && this.providerLifecycle.attemptedProviderId === this.settings.provider @@ -495,7 +578,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem }, ): Promise { opts?.onDebug?.({ backend: "builtin" }); - if (this.settings.providerRequirement.mode === "required") { + if (this.providerRequirement.mode === "required") { await this.ensureProviderInitialized(); this.assertRequiredProviderAvailable("search"); } diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 3a0e15fdf64d..6658191892f5 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -140,6 +140,8 @@ vi.mock("./sqlite-vec.js", () => ({ vi.mock("./embeddings.js", () => ({ resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, + resolveEmbeddingProviderAdapterTransport: (providerId: string) => + providerId === "local" ? "local" : "remote", createEmbeddingProvider: async () => ({ requestedProvider: "openai", provider: { diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 316aa85305e9..da5ecabad95a 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -204,7 +204,6 @@ describe("memory search config", () => { expect(resolved?.provider).toBe("openai"); expect(resolved?.model).toBe("text-embedding-3-small"); expect(resolved?.fallback).toBe("none"); - expect(resolved?.providerRequirement).toEqual({ mode: "optional", provider: "openai" }); }); it("normalizes legacy auto provider config to openai", () => { @@ -212,29 +211,24 @@ describe("memory search config", () => { expect(resolved?.provider).toBe("openai"); expect(resolved?.model).toBe("text-embedding-3-small"); - expect(resolved?.providerRequirement).toEqual({ mode: "optional", provider: "openai" }); }); - it("marks explicit concrete providers as required", () => { + it("resolves explicit concrete providers", () => { const resolved = resolveMemorySearchConfig(configWithDefaultProvider("openai"), "main"); - expect(resolved?.providerRequirement).toEqual({ - mode: "required", - provider: "openai", - configuredProvider: "openai", - }); + expect(resolved?.provider).toBe("openai"); }); - it("keeps explicit local providers optional so local degradation can fall back", () => { + it("resolves explicit local providers", () => { const resolved = resolveMemorySearchConfig(configWithDefaultProvider("local"), "main"); - expect(resolved?.providerRequirement).toEqual({ mode: "optional", provider: "local" }); + expect(resolved?.provider).toBe("local"); }); - it("marks explicit provider-none as fts-only", () => { + it("resolves explicit provider-none", () => { const resolved = resolveMemorySearchConfig(configWithDefaultProvider("none"), "main"); - expect(resolved?.providerRequirement).toEqual({ mode: "fts-only", provider: "none" }); + expect(resolved?.provider).toBe("none"); }); it("resolves custom provider ids through their configured api owner", () => { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index b9d835aab81d..1baf5c56c91b 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -34,11 +34,6 @@ export type ResolvedMemorySearchConfig = { extraPaths: string[]; multimodal: MemoryMultimodalSettings; provider: string; - providerRequirement: { - mode: "fts-only" | "optional" | "required"; - provider: string; - configuredProvider?: string; - }; remote?: { baseUrl?: string; apiKey?: SecretInput; @@ -213,21 +208,6 @@ function getConfiguredMemoryEmbeddingProvider( return getMemoryEmbeddingProvider(normalizedOwner); } -function resolveMemoryEmbeddingProviderRequirement( - rawProvider: string | undefined, - provider: string, - adapterTransport?: "local" | "remote", -): ResolvedMemorySearchConfig["providerRequirement"] { - const configuredProvider = rawProvider?.trim(); - if (configuredProvider === "none") { - return { mode: "fts-only", provider }; - } - if (!configuredProvider || configuredProvider === "auto" || adapterTransport === "local") { - return { mode: "optional", provider }; - } - return { mode: "required", provider, configuredProvider }; -} - function mergeConfig( cfg: OpenClawConfig, defaults: MemorySearchConfig | undefined, @@ -405,11 +385,6 @@ function mergeConfig( extraPaths, multimodal, provider, - providerRequirement: resolveMemoryEmbeddingProviderRequirement( - rawProvider, - provider, - primaryAdapter?.transport, - ), remote, experimental: { sessionMemory,