Skip to content

Commit 0aea58a

Browse files
authored
fix(memory): fail fast when embeddings provider is unavailable
Fixes #89691. Memory search now treats explicitly configured non-local embedding providers as required. When that provider is unavailable, search and sync surface an unavailable memory-search result instead of silently returning FTS-only recall. Unset/default/local/none-style paths keep FTS fallback so existing workflows do not lose keyword recall entirely. The fallback state is now surfaced in diagnostics/status instead of being hidden. Maintainer merge note: current CI still has unrelated baseline boundary failures in extensions/google/google.live.test.ts and extensions/minimax/minimax.live.test.ts. This PR does not touch those files; the PR-specific memory, docs, lint, type, security, and ClawSweeper checks were reviewed before merge.
1 parent 6b2af6c commit 0aea58a

14 files changed

Lines changed: 313 additions & 30 deletions

docs/concepts/memory-search.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,16 @@ flowchart LR
7676
- **BM25 keyword search** finds exact matches (IDs, error strings, config
7777
keys).
7878

79-
If only one path is available (no embeddings or no FTS), the other runs alone.
80-
81-
When embeddings are unavailable, OpenClaw still uses lexical ranking over FTS results instead of falling back to raw exact-match ordering only. That degraded mode boosts chunks with stronger query-term coverage and relevant file paths, which keeps recall useful even without `sqlite-vec` or an embedding provider.
79+
If only one path is available, the other runs alone. Intentional FTS-only mode
80+
(`provider: "none"`) and automatic/default provider selection can still use
81+
lexical ranking when embeddings are unavailable.
82+
83+
Explicit non-local embedding providers are different. If you set
84+
`memorySearch.provider` to a concrete remote-backed provider and that provider
85+
is unavailable at runtime, `memory_search` reports memory as unavailable instead
86+
of silently using FTS-only results. This keeps a broken configured semantic
87+
provider visible. Set `provider: "none"` for deliberate FTS-only recall, or fix
88+
the provider/auth configuration to restore semantic ranking.
8289

8390
## Improving search quality
8491

docs/reference/memory-config.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,17 @@ automatically re-embedding everything. Rebuild when you are ready with
6767
`openclaw memory index --force --agent <id>`.
6868
</Warning>
6969

70-
If OpenAI embeddings are unreachable from your network, memory recall fails open
71-
instead of blocking the turn. Set the existing `memorySearch.provider` field to a
72-
reachable local, Ollama, regional, or OpenAI-compatible provider to restore
73-
semantic ranking.
70+
When `provider` is unset, legacy `provider: "auto"` is present, or
71+
`provider: "none"` intentionally selects FTS-only mode, memory recall can still
72+
use lexical FTS ranking when embeddings are unavailable.
73+
74+
Explicit non-local providers fail closed. If you set `memorySearch.provider` to
75+
a concrete remote-backed provider such as OpenAI, Gemini, Voyage, Mistral,
76+
Bedrock, GitHub Copilot, DeepInfra, Ollama, LM Studio, or an OpenAI-compatible
77+
custom provider, and that provider is unavailable at runtime, `memory_search`
78+
returns an unavailable result instead of silently using FTS-only recall. Fix the
79+
provider/auth configuration, switch to a reachable provider, or set
80+
`provider: "none"` if you want deliberate FTS-only recall.
7481

7582
### Custom provider ids
7683

extensions/active-memory/index.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3162,7 +3162,7 @@ describe("active-memory plugin", () => {
31623162
expectLinesToContain(lines, "🔎 Active Memory Debug: backend=qmd searchMs=8 hits=0");
31633163
});
31643164

3165-
it("fast-fails unavailable memory_search results without injecting provider errors", async () => {
3165+
it("fast-fails configured-provider-missing memory_search results without injecting provider errors", async () => {
31663166
const CONFIGURED_TIMEOUT_MS = 1_000;
31673167
testing.setMinimumTimeoutMsForTests(1);
31683168
testing.setSetupGraceTimeoutMsForTests(0);
@@ -3185,7 +3185,8 @@ describe("active-memory plugin", () => {
31853185
disabled: true,
31863186
warning: "Memory search is unavailable due to an embedding/provider error.",
31873187
action: "Check the embedding provider configuration, then retry memory_search.",
3188-
error: "embedding request failed",
3188+
error:
3189+
'Memory search unavailable: embedding provider "openai" is configured but unavailable.',
31893190
},
31903191
},
31913192
},

extensions/memory-core/src/memory/embeddings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,17 @@ export function resolveEmbeddingProviderAdapterId(
158158
}
159159
}
160160

161+
export function resolveEmbeddingProviderAdapterTransport(
162+
providerId: string,
163+
config?: MemoryEmbeddingProviderCreateOptions["config"],
164+
): MemoryEmbeddingProviderAdapter["transport"] {
165+
try {
166+
return getAdapter(providerId, config).transport;
167+
} catch {
168+
return undefined;
169+
}
170+
}
171+
161172
async function createWithAdapter(
162173
adapter: MemoryEmbeddingProviderAdapter,
163174
options: CreateEmbeddingProviderOptions,

extensions/memory-core/src/memory/index.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ vi.mock("./embeddings.js", () => {
6868
};
6969
},
7070
) => config?.models?.providers?.[providerId]?.api ?? providerId,
71+
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
72+
providerId === "local" ? "local" : "remote",
7173
createEmbeddingProvider: async (options: {
7274
provider?: string;
7375
model?: string;
@@ -294,7 +296,7 @@ describe("memory index", () => {
294296
defaults: {
295297
workspace: workspaceDir,
296298
memorySearch: {
297-
provider: params.provider ?? "openai",
299+
...(params.provider !== undefined ? { provider: params.provider } : {}),
298300
model: params.model ?? "mock-embed",
299301
fallback: params.fallback,
300302
outputDimensionality: params.outputDimensionality,
@@ -974,6 +976,25 @@ describe("memory index", () => {
974976
expect(third).toBe(second);
975977
});
976978

979+
it("closes stale default managers when provider requirement changes", async () => {
980+
const storePath = path.join(workspaceDir, "index-provider-requirement-cache.sqlite");
981+
const implicitCfg = createCfg({ storePath });
982+
const implicit = requireManager(
983+
await getMemorySearchManager({ cfg: implicitCfg, agentId: "main" }),
984+
);
985+
managersForCleanup.add(implicit);
986+
await implicit.probeEmbeddingAvailability();
987+
988+
const explicitCfg = createCfg({ storePath, provider: "openai" });
989+
const explicit = requireManager(
990+
await getMemorySearchManager({ cfg: explicitCfg, agentId: "main" }),
991+
);
992+
managersForCleanup.add(explicit);
993+
994+
expect(explicit === implicit).toBe(false);
995+
expect(providerCloseCalls).toBe(1);
996+
});
997+
977998
it("retries embedding provider close before releasing the manager", async () => {
978999
providerCloseFailuresRemaining = 1;
9791000
const cfg = createCfg({
@@ -1604,6 +1625,12 @@ describe("memory index", () => {
16041625
const status = manager.status();
16051626
expect(status.chunks).toBeGreaterThan(0);
16061627
expect(embedBatchCalls).toBe(0);
1628+
expect(status.custom?.providerUnavailableReason).toBe("No API key found for provider");
1629+
expect(status.custom?.providerState).toEqual({
1630+
mode: "fts-only",
1631+
reason: "No API key found for provider",
1632+
attemptedProviderId: "openai",
1633+
});
16071634

16081635
const results = await manager.search("Alpha");
16091636
expect(results.length).toBeGreaterThan(0);
@@ -1613,6 +1640,56 @@ describe("memory index", () => {
16131640
expect(noResults.length).toBe(0);
16141641
});
16151642

1643+
it("fails fast instead of searching FTS when an explicit provider is unavailable", async () => {
1644+
forceNoProvider = true;
1645+
1646+
const cfg = createCfg({
1647+
storePath: path.join(workspaceDir, "index-required-provider-missing.sqlite"),
1648+
provider: "openai",
1649+
minScore: 0.35,
1650+
hybrid: { enabled: true },
1651+
});
1652+
const manager = await getFreshManager(cfg);
1653+
try {
1654+
await expect(manager.search("Alpha")).rejects.toThrow(
1655+
/Memory search unavailable: embedding provider "openai" is configured but unavailable\.[\s\S]*agentId=main purpose=default[\s\S]*registeredMemoryEmbeddingProviders=local/,
1656+
);
1657+
await expect(manager.sync({ reason: "test" })).rejects.toThrow(
1658+
/Memory sync unavailable: embedding provider "openai" is configured but unavailable\./,
1659+
);
1660+
forceNoProvider = false;
1661+
await manager.sync({ reason: "test", force: true });
1662+
const results = await manager.search("Alpha");
1663+
expect(results.length).toBeGreaterThan(0);
1664+
} finally {
1665+
await manager.close?.();
1666+
}
1667+
});
1668+
1669+
it("fails fast instead of returning FTS when an explicit provider is lost at runtime", async () => {
1670+
const cfg = createCfg({
1671+
storePath: path.join(workspaceDir, "index-required-provider-runtime-missing.sqlite"),
1672+
provider: "openai",
1673+
minScore: 0.35,
1674+
hybrid: { enabled: true },
1675+
});
1676+
const manager = await getFreshManager(cfg);
1677+
try {
1678+
await manager.sync({ reason: "test", force: true });
1679+
(
1680+
manager as unknown as {
1681+
provider: null;
1682+
}
1683+
).provider = null;
1684+
1685+
await expect(manager.search("Alpha")).rejects.toThrow(
1686+
/Memory search unavailable: embedding provider "openai" is configured but unavailable\./,
1687+
);
1688+
} finally {
1689+
await manager.close?.();
1690+
}
1691+
});
1692+
16161693
it("prefers exact session transcript hits in FTS-only mode", async () => {
16171694
try {
16181695
const manager = await getFtsSessionManager({

extensions/memory-core/src/memory/manager-sync-ops.archive-delta-bypass.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ class SessionDeltaHarness extends MemoryManagerSyncOps {
103103

104104
protected resetProviderInitializationForRetry(): void {}
105105

106+
protected assertRequiredProviderAvailable(): void {}
107+
106108
protected async indexFile(
107109
_entry: MemoryIndexEntry,
108110
_options: { source: MemorySource; content?: string },

extensions/memory-core/src/memory/manager-sync-ops.interval.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ class IntervalSyncHarness extends MemoryManagerSyncOps {
8282

8383
protected resetProviderInitializationForRetry(): void {}
8484

85+
protected assertRequiredProviderAvailable(): void {}
86+
8587
protected async indexFile(
8688
_entry: MemoryIndexEntry,
8789
_options: { source: MemorySource; content?: string },

extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
113113

114114
protected resetProviderInitializationForRetry(): void {}
115115

116+
protected assertRequiredProviderAvailable(): void {}
117+
116118
protected async indexFile(
117119
_entry: MemoryIndexEntry,
118120
_options: { source: MemorySource; content?: string },

extensions/memory-core/src/memory/manager-sync-ops.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ export abstract class MemoryManagerSyncOps {
280280
protected abstract getIndexConcurrency(): number;
281281
protected abstract pruneEmbeddingCacheIfNeeded(): void;
282282
protected abstract resetProviderInitializationForRetry(): void;
283+
protected abstract assertRequiredProviderAvailable(operation: "search" | "sync"): void;
283284
protected abstract indexFile(
284285
entry: MemoryIndexEntry,
285286
options: { source: MemorySource; content?: string },
@@ -1777,6 +1778,7 @@ export abstract class MemoryManagerSyncOps {
17771778
if (this.provider) {
17781779
return;
17791780
}
1781+
this.assertRequiredProviderAvailable("sync");
17801782
const existingMeta = this.readMeta();
17811783
if (
17821784
!existingMeta ||

extensions/memory-core/src/memory/manager-sync-yield.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => {
4040

4141
vi.mock("./embeddings.js", () => ({
4242
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
43+
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
44+
providerId === "local" ? "local" : "remote",
4345
createEmbeddingProvider: vi.fn(),
4446
}));
4547

@@ -132,6 +134,8 @@ class SessionSyncYieldHarness extends MemoryManagerSyncOps {
132134

133135
protected resetProviderInitializationForRetry(): void {}
134136

137+
protected assertRequiredProviderAvailable(): void {}
138+
135139
protected async indexFile(
136140
entry: MemoryIndexEntry,
137141
_options: { source: MemorySource; content?: string },

0 commit comments

Comments
 (0)