feat(memory): add Ollama embedding provider (#26349)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ac41386543
Co-authored-by: nico-hoff <43175972+nico-hoff@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
nico-hoff
2026-03-03 02:56:40 +01:00
committed by GitHub
parent 4ba5937ef9
commit 3eec79bd6c
17 changed files with 367 additions and 25 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
- Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (`MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `moonshot/kimi-k2.5`, `zai/glm-5`) and keep Moonshot model lists synced from shared source data.
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.

View File

@@ -109,6 +109,8 @@ Defaults:
6. Otherwise memory search stays disabled until configured.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
- `memorySearch.provider = "ollama"` is also supported for local/self-hosted
Ollama embeddings (`/api/embeddings`), but it is not auto-selected.
Remote embeddings **require** an API key for the embedding provider. OpenClaw
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
@@ -116,7 +118,9 @@ variables. Codex OAuth only covers chat/completions and does **not** satisfy
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
`models.providers.google.apiKey`. For Voyage, use `VOYAGE_API_KEY` or
`models.providers.voyage.apiKey`. For Mistral, use `MISTRAL_API_KEY` or
`models.providers.mistral.apiKey`.
`models.providers.mistral.apiKey`. Ollama typically does not require a real API
key (a placeholder like `OLLAMA_API_KEY=ollama-local` is enough when needed by
local policy).
When using a custom OpenAI-compatible endpoint,
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
@@ -331,7 +335,7 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se
Fallbacks:
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `local`, or `none`.
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`.
- The fallback provider is only used when the primary embedding provider fails.
Batch indexing (OpenAI + Gemini + Voyage):

View File

@@ -1299,12 +1299,13 @@ It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
resolves, then Voyage, then Mistral. If no remote key is available, memory
search stays disabled until you configure it. If you have a local model path
configured and present, OpenClaw
prefers `local`.
prefers `local`. Ollama is supported when you explicitly set
`memorySearch.provider = "ollama"`.
If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, or local** embedding
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, Ollama, or local** embedding
models - see [Memory](/concepts/memory) for the setup details.
### Does memory persist forever What are the limits

View File

@@ -68,6 +68,7 @@ Semantic memory search uses **embedding APIs** when configured for remote provid
- `memorySearch.provider = "gemini"` → Gemini embeddings
- `memorySearch.provider = "voyage"` → Voyage embeddings
- `memorySearch.provider = "mistral"` → Mistral embeddings
- `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
- Optional fallback to a remote provider if local embeddings fail
You can keep it local with `memorySearch.provider = "local"` (no API usage).

View File

@@ -6,7 +6,7 @@ const asConfig = (cfg: OpenClawConfig): OpenClawConfig => cfg;
describe("memory search config", () => {
function configWithDefaultProvider(
provider: "openai" | "local" | "gemini" | "mistral",
provider: "openai" | "local" | "gemini" | "mistral" | "ollama",
): OpenClawConfig {
return asConfig({
agents: {
@@ -156,6 +156,13 @@ describe("memory search config", () => {
expect(resolved?.model).toBe("mistral-embed");
});
it("includes remote defaults and model default for ollama without overrides", () => {
const cfg = configWithDefaultProvider("ollama");
const resolved = resolveMemorySearchConfig(cfg, "main");
expectDefaultRemoteBatch(resolved);
expect(resolved?.model).toBe("nomic-embed-text");
});
it("defaults session delta thresholds", () => {
const cfg = asConfig({
agents: {

View File

@@ -9,7 +9,7 @@ export type ResolvedMemorySearchConfig = {
enabled: boolean;
sources: Array<"memory" | "sessions">;
extraPaths: string[];
provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "auto";
provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto";
remote?: {
baseUrl?: string;
apiKey?: string;
@@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = {
experimental: {
sessionMemory: boolean;
};
fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "none";
fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none";
model: string;
local: {
modelPath?: string;
@@ -82,6 +82,7 @@ const DEFAULT_OPENAI_MODEL = "text-embedding-3-small";
const DEFAULT_GEMINI_MODEL = "gemini-embedding-001";
const DEFAULT_VOYAGE_MODEL = "voyage-4-large";
const DEFAULT_MISTRAL_MODEL = "mistral-embed";
const DEFAULT_OLLAMA_MODEL = "nomic-embed-text";
const DEFAULT_CHUNK_TOKENS = 400;
const DEFAULT_CHUNK_OVERLAP = 80;
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
@@ -155,6 +156,7 @@ function mergeConfig(
provider === "gemini" ||
provider === "voyage" ||
provider === "mistral" ||
provider === "ollama" ||
provider === "auto";
const batch = {
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? false,
@@ -186,7 +188,9 @@ function mergeConfig(
? DEFAULT_VOYAGE_MODEL
: provider === "mistral"
? DEFAULT_MISTRAL_MODEL
: undefined;
: provider === "ollama"
? DEFAULT_OLLAMA_MODEL
: undefined;
const model = overrides?.model ?? defaults?.model ?? modelDefault ?? "";
const local = {
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,

View File

@@ -235,6 +235,31 @@ describe("noteMemorySearchHealth", () => {
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("openclaw configure --section model");
});
it("still warns in auto mode when only ollama credentials exist", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
resolveApiKeyForProvider.mockImplementation(async ({ provider }: { provider: string }) => {
if (provider === "ollama") {
return {
apiKey: "ollama-local",
source: "env: OLLAMA_API_KEY",
mode: "api-key",
};
}
throw new Error("missing key");
});
await noteMemorySearchHealth(cfg);
expect(note).toHaveBeenCalledTimes(1);
const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>;
const providersChecked = providerCalls.map(([arg]) => arg.provider);
expect(providersChecked).toEqual(["openai", "google", "voyage", "mistral"]);
});
});
describe("detectLegacyWorkspaceDirs", () => {

View File

@@ -186,7 +186,7 @@ function hasLocalEmbeddings(local: { modelPath?: string }, useDefaultFallback =
}
async function hasApiKeyForProvider(
provider: "openai" | "gemini" | "voyage" | "mistral",
provider: "openai" | "gemini" | "voyage" | "mistral" | "ollama",
cfg: OpenClawConfig,
agentDir: string,
): Promise<boolean> {

View File

@@ -724,7 +724,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.memorySearch.experimental.sessionMemory":
"Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.",
"agents.defaults.memorySearch.provider":
'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", or "local". Keep your most reliable provider here and configure fallback for resilience.',
'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.',
"agents.defaults.memorySearch.model":
"Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.",
"agents.defaults.memorySearch.remote.baseUrl":
@@ -746,7 +746,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.memorySearch.local.modelPath":
"Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.",
"agents.defaults.memorySearch.fallback":
'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.',
'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.',
"agents.defaults.memorySearch.store.path":
"Sets where the SQLite memory index is stored on disk for each agent. Keep the default `~/.openclaw/memory/{agentId}.sqlite` unless you need custom storage placement or backup policy alignment.",
"agents.defaults.memorySearch.store.vector.enabled":

View File

@@ -324,7 +324,7 @@ export type MemorySearchConfig = {
sessionMemory?: boolean;
};
/** Embedding provider mode. */
provider?: "openai" | "gemini" | "local" | "voyage" | "mistral";
provider?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama";
remote?: {
baseUrl?: string;
apiKey?: string;
@@ -343,7 +343,7 @@ export type MemorySearchConfig = {
};
};
/** Fallback behavior when embeddings fail. */
fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "none";
fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none";
/** Embedding model id (remote) or alias (local). */
model?: string;
/** Local embedding settings (node-llama-cpp). */

View File

@@ -557,6 +557,7 @@ export const MemorySearchSchema = z
z.literal("gemini"),
z.literal("voyage"),
z.literal("mistral"),
z.literal("ollama"),
])
.optional(),
remote: z
@@ -584,6 +585,7 @@ export const MemorySearchSchema = z
z.literal("local"),
z.literal("voyage"),
z.literal("mistral"),
z.literal("ollama"),
z.literal("none"),
])
.optional(),

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createOllamaEmbeddingProvider } from "./embeddings-ollama.js";
describe("embeddings-ollama", () => {
it("calls /api/embeddings and returns normalized vectors", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ embedding: [3, 4] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
globalThis.fetch = fetchMock;
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
const v = await provider.embedQuery("hi");
expect(fetchMock).toHaveBeenCalledTimes(1);
// normalized [3,4] => [0.6,0.8]
expect(v[0]).toBeCloseTo(0.6, 5);
expect(v[1]).toBeCloseTo(0.8, 5);
});
it("resolves baseUrl/apiKey/headers from models.providers.ollama and strips /v1", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ embedding: [1, 0] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
globalThis.fetch = fetchMock;
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434/v1",
apiKey: "ollama-local",
headers: {
"X-Provider-Header": "provider",
},
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama",
model: "",
fallback: "none",
});
await provider.embedQuery("hello");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embeddings",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer ollama-local",
"X-Provider-Header": "provider",
}),
}),
);
});
});

View File

@@ -0,0 +1,137 @@
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js";
import { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./remote-http.js";
export type OllamaEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
model: string;
embedBatch: (texts: string[]) => Promise<number[][]>;
};
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
if (magnitude < 1e-10) {
return sanitized;
}
return sanitized.map((value) => value / magnitude);
}
function normalizeOllamaModel(model: string): string {
const trimmed = model.trim();
if (!trimmed) {
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
}
if (trimmed.startsWith("ollama/")) {
return trimmed.slice("ollama/".length);
}
return trimmed;
}
function resolveOllamaApiBase(configuredBaseUrl?: string): string {
if (!configuredBaseUrl) {
return DEFAULT_OLLAMA_BASE_URL;
}
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
function resolveOllamaApiKey(options: EmbeddingProviderOptions): string | undefined {
const remoteApiKey = options.remote?.apiKey?.trim();
if (remoteApiKey) {
return remoteApiKey;
}
const providerApiKey = normalizeOptionalSecretInput(
options.config.models?.providers?.ollama?.apiKey,
);
if (providerApiKey) {
return providerApiKey;
}
return resolveEnvApiKey("ollama")?.apiKey;
}
function resolveOllamaEmbeddingClient(
options: EmbeddingProviderOptions,
): OllamaEmbeddingClientConfig {
const providerConfig = options.config.models?.providers?.ollama;
const rawBaseUrl = options.remote?.baseUrl?.trim() || providerConfig?.baseUrl?.trim();
const baseUrl = resolveOllamaApiBase(rawBaseUrl);
const model = normalizeOllamaModel(options.model);
const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...headerOverrides,
};
const apiKey = resolveOllamaApiKey(options);
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
return {
baseUrl,
headers,
ssrfPolicy: buildRemoteBaseUrlPolicy(baseUrl),
model,
};
}
export async function createOllamaEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<{ provider: EmbeddingProvider; client: OllamaEmbeddingClient }> {
const client = resolveOllamaEmbeddingClient(options);
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embeddings`;
const embedOne = async (text: string): Promise<number[]> => {
const json = await withRemoteHttpResponse({
url: embedUrl,
ssrfPolicy: client.ssrfPolicy,
init: {
method: "POST",
headers: client.headers,
body: JSON.stringify({ model: client.model, prompt: text }),
},
onResponse: async (res) => {
if (!res.ok) {
throw new Error(`Ollama embeddings HTTP ${res.status}: ${await res.text()}`);
}
return (await res.json()) as { embedding?: number[] };
},
});
if (!Array.isArray(json.embedding)) {
throw new Error(`Ollama embeddings response missing embedding[]`);
}
return sanitizeAndNormalizeEmbedding(json.embedding);
};
const provider: EmbeddingProvider = {
id: "ollama",
model: client.model,
embedQuery: embedOne,
embedBatch: async (texts: string[]) => {
// Ollama /api/embeddings accepts one prompt per request.
return await Promise.all(texts.map(embedOne));
},
};
return {
provider,
client: {
...client,
embedBatch: async (texts) => {
try {
return await provider.embedBatch(texts);
} catch (err) {
throw new Error(formatErrorMessage(err), { cause: err });
}
},
},
};
}

View File

@@ -8,6 +8,7 @@ import {
createMistralEmbeddingProvider,
type MistralEmbeddingClient,
} from "./embeddings-mistral.js";
import { createOllamaEmbeddingProvider, type OllamaEmbeddingClient } from "./embeddings-ollama.js";
import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js";
import { createVoyageEmbeddingProvider, type VoyageEmbeddingClient } from "./embeddings-voyage.js";
import { importNodeLlamaCpp } from "./node-llama.js";
@@ -25,6 +26,7 @@ export type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
export type { MistralEmbeddingClient } from "./embeddings-mistral.js";
export type { OpenAiEmbeddingClient } from "./embeddings-openai.js";
export type { VoyageEmbeddingClient } from "./embeddings-voyage.js";
export type { OllamaEmbeddingClient } from "./embeddings-ollama.js";
export type EmbeddingProvider = {
id: string;
@@ -34,10 +36,13 @@ export type EmbeddingProvider = {
embedBatch: (texts: string[]) => Promise<number[][]>;
};
export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral";
export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama";
export type EmbeddingProviderRequest = EmbeddingProviderId | "auto";
export type EmbeddingProviderFallback = EmbeddingProviderId | "none";
// Remote providers considered for auto-selection when provider === "auto".
// Ollama is intentionally excluded here so that "auto" mode does not
// implicitly assume a local Ollama instance is available.
const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage", "mistral"] as const;
export type EmbeddingProviderResult = {
@@ -50,6 +55,7 @@ export type EmbeddingProviderResult = {
gemini?: GeminiEmbeddingClient;
voyage?: VoyageEmbeddingClient;
mistral?: MistralEmbeddingClient;
ollama?: OllamaEmbeddingClient;
};
export type EmbeddingProviderOptions = {
@@ -152,6 +158,10 @@ export async function createEmbeddingProvider(
const provider = await createLocalEmbeddingProvider(options);
return { provider };
}
if (id === "ollama") {
const { provider, client } = await createOllamaEmbeddingProvider(options);
return { provider, ollama: client };
}
if (id === "gemini") {
const { provider, client } = await createGeminiEmbeddingProvider(options);
return { provider, gemini: client };

View File

@@ -13,6 +13,7 @@ import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js";
import { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "./embeddings-mistral.js";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js";
import { DEFAULT_OPENAI_EMBEDDING_MODEL } from "./embeddings-openai.js";
import { DEFAULT_VOYAGE_EMBEDDING_MODEL } from "./embeddings-voyage.js";
import {
@@ -20,6 +21,7 @@ import {
type EmbeddingProvider,
type GeminiEmbeddingClient,
type MistralEmbeddingClient,
type OllamaEmbeddingClient,
type OpenAiEmbeddingClient,
type VoyageEmbeddingClient,
} from "./embeddings.js";
@@ -91,11 +93,12 @@ export abstract class MemoryManagerSyncOps {
protected abstract readonly workspaceDir: string;
protected abstract readonly settings: ResolvedMemorySearchConfig;
protected provider: EmbeddingProvider | null = null;
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral";
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama";
protected openAi?: OpenAiEmbeddingClient;
protected gemini?: GeminiEmbeddingClient;
protected voyage?: VoyageEmbeddingClient;
protected mistral?: MistralEmbeddingClient;
protected ollama?: OllamaEmbeddingClient;
protected abstract batch: {
enabled: boolean;
wait: boolean;
@@ -350,7 +353,10 @@ export abstract class MemoryManagerSyncOps {
this.fts.available = result.ftsAvailable;
if (result.ftsError) {
this.fts.loadError = result.ftsError;
log.warn(`fts unavailable: ${result.ftsError}`);
// Only warn when hybrid search is enabled; otherwise this is expected noise.
if (this.fts.enabled) {
log.warn(`fts unavailable: ${result.ftsError}`);
}
}
}
@@ -958,7 +964,13 @@ export abstract class MemoryManagerSyncOps {
if (this.fallbackFrom) {
return false;
}
const fallbackFrom = this.provider.id as "openai" | "gemini" | "local" | "voyage" | "mistral";
const fallbackFrom = this.provider.id as
| "openai"
| "gemini"
| "local"
| "voyage"
| "mistral"
| "ollama";
const fallbackModel =
fallback === "gemini"
@@ -969,7 +981,9 @@ export abstract class MemoryManagerSyncOps {
? DEFAULT_VOYAGE_EMBEDDING_MODEL
: fallback === "mistral"
? DEFAULT_MISTRAL_EMBEDDING_MODEL
: this.settings.model;
: fallback === "ollama"
? DEFAULT_OLLAMA_EMBEDDING_MODEL
: this.settings.model;
const fallbackResult = await createEmbeddingProvider({
config: this.cfg,
@@ -988,6 +1002,7 @@ export abstract class MemoryManagerSyncOps {
this.gemini = fallbackResult.gemini;
this.voyage = fallbackResult.voyage;
this.mistral = fallbackResult.mistral;
this.ollama = fallbackResult.ollama;
this.providerKey = this.computeProviderKey();
this.batch = this.resolveBatchConfig();
log.warn(`memory embeddings: switched to fallback provider (${fallback})`, { reason });

View File

@@ -3,10 +3,12 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js";
import type {
EmbeddingProvider,
EmbeddingProviderResult,
MistralEmbeddingClient,
OllamaEmbeddingClient,
OpenAiEmbeddingClient,
} from "./embeddings.js";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
@@ -36,7 +38,7 @@ function buildConfig(params: {
workspaceDir: string;
indexPath: string;
provider: "openai" | "mistral";
fallback?: "none" | "mistral";
fallback?: "none" | "mistral" | "ollama";
}): OpenClawConfig {
return {
agents: {
@@ -144,4 +146,51 @@ describe("memory manager mistral provider wiring", () => {
expect(internal.openAi).toBeUndefined();
expect(internal.mistral).toBe(mistralClient);
});
it("uses default ollama model when activating ollama fallback", async () => {
const openAiClient: OpenAiEmbeddingClient = {
baseUrl: "https://api.openai.com/v1",
headers: { authorization: "Bearer openai-key" },
model: "text-embedding-3-small",
};
const ollamaClient: OllamaEmbeddingClient = {
baseUrl: "http://127.0.0.1:11434",
headers: {},
model: DEFAULT_OLLAMA_EMBEDDING_MODEL,
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]),
};
createEmbeddingProviderMock.mockResolvedValueOnce({
requestedProvider: "openai",
provider: createProvider("openai"),
openAi: openAiClient,
} as EmbeddingProviderResult);
createEmbeddingProviderMock.mockResolvedValueOnce({
requestedProvider: "ollama",
provider: createProvider("ollama"),
ollama: ollamaClient,
} as EmbeddingProviderResult);
const cfg = buildConfig({ workspaceDir, indexPath, provider: "openai", fallback: "ollama" });
const result = await getMemorySearchManager({ cfg, agentId: "main" });
if (!result.manager) {
throw new Error(`manager missing: ${result.error ?? "no error provided"}`);
}
manager = result.manager as unknown as MemoryIndexManager;
const internal = manager as unknown as {
activateFallbackProvider: (reason: string) => Promise<boolean>;
openAi?: OpenAiEmbeddingClient;
ollama?: OllamaEmbeddingClient;
};
const activated = await internal.activateFallbackProvider("forced ollama fallback");
expect(activated).toBe(true);
expect(internal.openAi).toBeUndefined();
expect(internal.ollama).toBe(ollamaClient);
const fallbackCall = createEmbeddingProviderMock.mock.calls[1]?.[0] as
| { provider?: string; model?: string }
| undefined;
expect(fallbackCall?.provider).toBe("ollama");
expect(fallbackCall?.model).toBe(DEFAULT_OLLAMA_EMBEDDING_MODEL);
});
});

View File

@@ -13,6 +13,7 @@ import {
type EmbeddingProviderResult,
type GeminiEmbeddingClient,
type MistralEmbeddingClient,
type OllamaEmbeddingClient,
type OpenAiEmbeddingClient,
type VoyageEmbeddingClient,
} from "./embeddings.js";
@@ -48,14 +49,22 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
protected readonly workspaceDir: string;
protected readonly settings: ResolvedMemorySearchConfig;
protected provider: EmbeddingProvider | null;
private readonly requestedProvider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "auto";
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral";
private readonly requestedProvider:
| "openai"
| "local"
| "gemini"
| "voyage"
| "mistral"
| "ollama"
| "auto";
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama";
protected fallbackReason?: string;
private readonly providerUnavailableReason?: string;
protected openAi?: OpenAiEmbeddingClient;
protected gemini?: GeminiEmbeddingClient;
protected voyage?: VoyageEmbeddingClient;
protected mistral?: MistralEmbeddingClient;
protected ollama?: OllamaEmbeddingClient;
protected batch: {
enabled: boolean;
wait: boolean;
@@ -185,6 +194,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
this.gemini = params.providerResult.gemini;
this.voyage = params.providerResult.voyage;
this.mistral = params.providerResult.mistral;
this.ollama = params.providerResult.ollama;
this.sources = new Set(params.settings.sources);
this.db = this.openDatabase();
this.providerKey = this.computeProviderKey();
@@ -289,9 +299,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
return merged;
}
const keywordResults = hybrid.enabled
? await this.searchKeyword(cleaned, candidates).catch(() => [])
: [];
// If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
const keywordResults =
hybrid.enabled && this.fts.enabled && this.fts.available
? await this.searchKeyword(cleaned, candidates).catch(() => [])
: [];
const queryVec = await this.embedQueryWithTimeout(cleaned);
const hasVector = queryVec.some((v) => v !== 0);
@@ -299,7 +311,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
? await this.searchVector(queryVec, candidates).catch(() => [])
: [];
if (!hybrid.enabled) {
if (!hybrid.enabled || !this.fts.enabled || !this.fts.available) {
return vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults);
}