diff --git a/src/agents/tools/memory-tool.ts b/src/agents/tools/memory-tool.ts index e0cb82d6d41..d37d7ad6cd5 100644 --- a/src/agents/tools/memory-tool.ts +++ b/src/agents/tools/memory-tool.ts @@ -81,12 +81,14 @@ export function createMemorySearchTool(options: { status.backend === "qmd" ? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars) : decorated; + const searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode; return jsonResult({ results, provider: status.provider, model: status.model, fallback: status.fallback, citations: citationsMode, + mode: searchMode, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index 6da3c5b5bc4..98971c389da 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -432,3 +432,63 @@ describe("local embedding normalization", () => { } }); }); + +describe("FTS-only fallback when no provider available", () => { + afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllGlobals(); + }); + + it("returns null provider with reason when auto mode finds no providers", async () => { + vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( + new Error('No API key found for provider "openai"'), + ); + + const result = await createEmbeddingProvider({ + config: {} as never, + provider: "auto", + model: "", + fallback: "none", + }); + + expect(result.provider).toBeNull(); + expect(result.requestedProvider).toBe("auto"); + expect(result.providerUnavailableReason).toBeDefined(); + expect(result.providerUnavailableReason).toContain("No API key"); + }); + + it("returns null provider when explicit provider fails with missing API key", async () => { + vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( + new Error('No API key found for provider "openai"'), + ); + + const result = await createEmbeddingProvider({ + config: {} as never, + provider: "openai", + model: "text-embedding-3-small", + fallback: "none", + }); + + expect(result.provider).toBeNull(); + expect(result.requestedProvider).toBe("openai"); + expect(result.providerUnavailableReason).toBeDefined(); + }); + + it("returns null provider when both primary and fallback fail with missing API keys", async () => { + vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( + new Error("No API key found for provider"), + ); + + const result = await createEmbeddingProvider({ + config: {} as never, + provider: "openai", + model: "text-embedding-3-small", + fallback: "gemini", + }); + + expect(result.provider).toBeNull(); + expect(result.requestedProvider).toBe("openai"); + expect(result.fallbackFrom).toBe("openai"); + expect(result.providerUnavailableReason).toContain("Fallback to gemini failed"); + }); +}); diff --git a/src/memory/embeddings.ts b/src/memory/embeddings.ts index 9ff37f00f61..925210968e6 100644 --- a/src/memory/embeddings.ts +++ b/src/memory/embeddings.ts @@ -36,10 +36,11 @@ export type EmbeddingProviderFallback = EmbeddingProviderId | "none"; const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage"] as const; export type EmbeddingProviderResult = { - provider: EmbeddingProvider; + provider: EmbeddingProvider | null; requestedProvider: EmbeddingProviderRequest; fallbackFrom?: EmbeddingProviderId; fallbackReason?: string; + providerUnavailableReason?: string; openAi?: OpenAiEmbeddingClient; gemini?: GeminiEmbeddingClient; voyage?: VoyageEmbeddingClient; @@ -183,15 +184,19 @@ export async function createEmbeddingProvider( missingKeyErrors.push(message); continue; } + // Non-auth errors (e.g., network) are still fatal throw new Error(message, { cause: err }); } } + // All providers failed due to missing API keys - return null provider for FTS-only mode const details = [...missingKeyErrors, localError].filter(Boolean) as string[]; - if (details.length > 0) { - throw new Error(details.join("\n\n")); - } - throw new Error("No embeddings provider available."); + const reason = details.length > 0 ? details.join("\n\n") : "No embeddings provider available."; + return { + provider: null, + requestedProvider, + providerUnavailableReason: reason, + }; } try { @@ -209,13 +214,31 @@ export async function createEmbeddingProvider( fallbackReason: reason, }; } catch (fallbackErr) { - // oxlint-disable-next-line preserve-caught-error - throw new Error( - `${reason}\n\nFallback to ${fallback} failed: ${formatErrorMessage(fallbackErr)}`, - { cause: fallbackErr }, - ); + // Both primary and fallback failed - check if it's auth-related + const fallbackReason = formatErrorMessage(fallbackErr); + const combinedReason = `${reason}\n\nFallback to ${fallback} failed: ${fallbackReason}`; + if (isMissingApiKeyError(primaryErr) && isMissingApiKeyError(fallbackErr)) { + // Both failed due to missing API keys - return null for FTS-only mode + return { + provider: null, + requestedProvider, + fallbackFrom: requestedProvider, + fallbackReason: reason, + providerUnavailableReason: combinedReason, + }; + } + // Non-auth errors are still fatal + throw new Error(combinedReason, { cause: fallbackErr }); } } + // No fallback configured - check if we should degrade to FTS-only + if (isMissingApiKeyError(primaryErr)) { + return { + provider: null, + requestedProvider, + providerUnavailableReason: reason, + }; + } throw new Error(reason, { cause: primaryErr }); } } diff --git a/src/memory/manager-embedding-ops.ts b/src/memory/manager-embedding-ops.ts index 72b5c25c46b..d153c673a34 100644 --- a/src/memory/manager-embedding-ops.ts +++ b/src/memory/manager-embedding-ops.ts @@ -202,6 +202,10 @@ class MemoryManagerEmbeddingOps { } private computeProviderKey(): string { + // FTS-only mode: no provider, use a constant key + if (!this.provider) { + return hashText(JSON.stringify({ provider: "none", model: "fts-only" })); + } if (this.provider.id === "openai" && this.openAi) { const entries = Object.entries(this.openAi.headers) .filter(([key]) => key.toLowerCase() !== "authorization") diff --git a/src/memory/manager-search.ts b/src/memory/manager-search.ts index f77751a618b..a3c8c06146a 100644 --- a/src/memory/manager-search.ts +++ b/src/memory/manager-search.ts @@ -136,7 +136,7 @@ export function listChunks(params: { export async function searchKeyword(params: { db: DatabaseSync; ftsTable: string; - providerModel: string; + providerModel: string | undefined; query: string; limit: number; snippetMaxChars: number; @@ -152,16 +152,20 @@ export async function searchKeyword(params: { return []; } + // When providerModel is undefined (FTS-only mode), search all models + const modelClause = params.providerModel ? " AND model = ?" : ""; + const modelParams = params.providerModel ? [params.providerModel] : []; + const rows = params.db .prepare( `SELECT id, path, source, start_line, end_line, text,\n` + ` bm25(${params.ftsTable}) AS rank\n` + ` FROM ${params.ftsTable}\n` + - ` WHERE ${params.ftsTable} MATCH ? AND model = ?${params.sourceFilter.sql}\n` + + ` WHERE ${params.ftsTable} MATCH ?${modelClause}${params.sourceFilter.sql}\n` + ` ORDER BY rank ASC\n` + ` LIMIT ?`, ) - .all(ftsQuery, params.providerModel, ...params.sourceFilter.params, params.limit) as Array<{ + .all(ftsQuery, ...modelParams, ...params.sourceFilter.params, params.limit) as Array<{ id: string; path: string; source: SearchSource; diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 9f0e0c6ec97..e63d46eb708 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -46,10 +46,11 @@ export class MemoryIndexManager implements MemorySearchManager { private readonly agentId: string; private readonly workspaceDir: string; private readonly settings: ResolvedMemorySearchConfig; - private provider: EmbeddingProvider; + private provider: EmbeddingProvider | null; private readonly requestedProvider: "openai" | "local" | "gemini" | "voyage" | "auto"; private fallbackFrom?: "openai" | "local" | "gemini" | "voyage"; private fallbackReason?: string; + private readonly providerUnavailableReason?: string; private openAi?: OpenAiEmbeddingClient; private gemini?: GeminiEmbeddingClient; private voyage?: VoyageEmbeddingClient; @@ -154,6 +155,7 @@ export class MemoryIndexManager implements MemorySearchManager { this.requestedProvider = params.providerResult.requestedProvider; this.fallbackFrom = params.providerResult.fallbackFrom; this.fallbackReason = params.providerResult.fallbackReason; + this.providerUnavailableReason = params.providerResult.providerUnavailableReason; this.openAi = params.providerResult.openAi; this.gemini = params.providerResult.gemini; this.voyage = params.providerResult.voyage; @@ -225,6 +227,16 @@ export class MemoryIndexManager implements MemorySearchManager { Math.max(1, Math.floor(maxResults * hybrid.candidateMultiplier)), ); + // FTS-only mode: no embedding provider available + if (!this.provider) { + if (!this.fts.enabled || !this.fts.available) { + log.warn("memory search: no provider and FTS unavailable"); + return []; + } + const ftsResults = await this.searchKeyword(cleaned, candidates).catch(() => []); + return ftsResults.filter((entry) => entry.score >= minScore).slice(0, maxResults); + } + const keywordResults = hybrid.enabled ? await this.searchKeyword(cleaned, candidates).catch(() => []) : []; @@ -253,6 +265,10 @@ export class MemoryIndexManager implements MemorySearchManager { queryVec: number[], limit: number, ): Promise> { + // This method should never be called without a provider + if (!this.provider) { + return []; + } const results = await searchVector({ db: this.db, vectorTable: VECTOR_TABLE, @@ -279,10 +295,12 @@ export class MemoryIndexManager implements MemorySearchManager { return []; } const sourceFilter = this.buildSourceFilter(); + // In FTS-only mode (no provider), search all models; otherwise filter by current provider's model + const providerModel = this.provider?.model; const results = await searchKeyword({ db: this.db, ftsTable: FTS_TABLE, - providerModel: this.provider.model, + providerModel, query, limit, snippetMaxChars: SNIPPET_MAX_CHARS, @@ -446,6 +464,13 @@ export class MemoryIndexManager implements MemorySearchManager { } return sources.map((source) => Object.assign({ source }, bySource.get(source)!)); })(); + + // Determine search mode: "fts-only" if no provider, "hybrid" otherwise + const searchMode = this.provider ? "hybrid" : "fts-only"; + const providerInfo = this.provider + ? { provider: this.provider.id, model: this.provider.model } + : { provider: "none", model: undefined }; + return { backend: "builtin", files: files?.c ?? 0, @@ -453,8 +478,8 @@ export class MemoryIndexManager implements MemorySearchManager { dirty: this.dirty || this.sessionsDirty, workspaceDir: this.workspaceDir, dbPath: this.settings.store.path, - provider: this.provider.id, - model: this.provider.model, + provider: providerInfo.provider, + model: providerInfo.model, requestedProvider: this.requestedProvider, sources: Array.from(this.sources), extraPaths: this.settings.extraPaths, @@ -497,10 +522,18 @@ export class MemoryIndexManager implements MemorySearchManager { lastError: this.batchFailureLastError, lastProvider: this.batchFailureLastProvider, }, + custom: { + searchMode, + providerUnavailableReason: this.providerUnavailableReason, + }, }; } async probeVectorAvailability(): Promise { + // FTS-only mode: vector search not available + if (!this.provider) { + return false; + } if (!this.vector.enabled) { return false; } @@ -508,6 +541,13 @@ export class MemoryIndexManager implements MemorySearchManager { } async probeEmbeddingAvailability(): Promise { + // FTS-only mode: embeddings not available but search still works + if (!this.provider) { + return { + ok: false, + error: this.providerUnavailableReason ?? "No embedding provider available (FTS-only mode)", + }; + } try { await this.embedBatchWithRetry(["ping"]); return { ok: true };