From 69ba9a05625119f9cdf6195793dbdc635b1c129d Mon Sep 17 00:00:00 2001 From: Steve Date: Sat, 14 Feb 2026 13:09:51 -0400 Subject: [PATCH] fix: add memory search health check to openclaw doctor (openclaw#16294) thanks @superlowburn Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test (noted unrelated local flakes) Co-authored-by: superlowburn <24779772+superlowburn@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- src/commands/doctor-memory-search.test.ts | 87 ++++++++++++++ src/commands/doctor-memory-search.ts | 139 ++++++++++++++++++++++ src/commands/doctor.ts | 2 + 3 files changed, 228 insertions(+) create mode 100644 src/commands/doctor-memory-search.test.ts create mode 100644 src/commands/doctor-memory-search.ts diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts new file mode 100644 index 00000000000..334e0518214 --- /dev/null +++ b/src/commands/doctor-memory-search.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const note = vi.hoisted(() => vi.fn()); +const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "agent-default")); +const resolveAgentDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default")); +const resolveMemorySearchConfig = vi.hoisted(() => vi.fn()); +const resolveApiKeyForProvider = vi.hoisted(() => vi.fn()); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveDefaultAgentId, + resolveAgentDir, +})); + +vi.mock("../agents/memory-search.js", () => ({ + resolveMemorySearchConfig, +})); + +vi.mock("../agents/model-auth.js", () => ({ + resolveApiKeyForProvider, +})); + +import { noteMemorySearchHealth } from "./doctor-memory-search.js"; + +describe("noteMemorySearchHealth", () => { + const cfg = {} as OpenClawConfig; + + beforeEach(() => { + note.mockReset(); + resolveDefaultAgentId.mockClear(); + resolveAgentDir.mockClear(); + resolveMemorySearchConfig.mockReset(); + resolveApiKeyForProvider.mockReset(); + }); + + it("does not warn when remote apiKey is configured for explicit provider", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "openai", + local: {}, + remote: { apiKey: "from-config" }, + }); + + await noteMemorySearchHealth(cfg); + + expect(note).not.toHaveBeenCalled(); + expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); + }); + + it("does not warn in auto mode when remote apiKey is configured", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: { apiKey: "from-config" }, + }); + + await noteMemorySearchHealth(cfg); + + expect(note).not.toHaveBeenCalled(); + expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); + }); + + it("resolves provider auth from the default agent directory", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "gemini", + local: {}, + remote: {}, + }); + resolveApiKeyForProvider.mockResolvedValue({ + apiKey: "k", + source: "env: GEMINI_API_KEY", + mode: "api-key", + }); + + await noteMemorySearchHealth(cfg); + + expect(resolveApiKeyForProvider).toHaveBeenCalledWith({ + provider: "google", + cfg, + agentDir: "/tmp/agent-default", + }); + expect(note).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts new file mode 100644 index 00000000000..5ceefd42051 --- /dev/null +++ b/src/commands/doctor-memory-search.ts @@ -0,0 +1,139 @@ +import fsSync from "node:fs"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveMemorySearchConfig } from "../agents/memory-search.js"; +import { resolveApiKeyForProvider } from "../agents/model-auth.js"; +import { formatCliCommand } from "../cli/command-format.js"; +import { note } from "../terminal/note.js"; +import { resolveUserPath } from "../utils.js"; + +/** + * Check whether memory search has a usable embedding provider. + * Runs as part of `openclaw doctor` — config-only, no network calls. + */ +export async function noteMemorySearchHealth(cfg: OpenClawConfig): Promise { + const agentId = resolveDefaultAgentId(cfg); + const agentDir = resolveAgentDir(cfg, agentId); + const resolved = resolveMemorySearchConfig(cfg, agentId); + const hasRemoteApiKey = Boolean(resolved?.remote?.apiKey?.trim()); + + if (!resolved) { + note("Memory search is explicitly disabled (enabled: false).", "Memory search"); + return; + } + + // If a specific provider is configured (not "auto"), check only that one. + if (resolved.provider !== "auto") { + if (resolved.provider === "local") { + if (hasLocalEmbeddings(resolved.local)) { + return; // local model file exists + } + note( + [ + 'Memory search provider is set to "local" but no local model file was found.', + "", + "Fix (pick one):", + `- Install node-llama-cpp and set a local model path in config`, + `- Switch to a remote provider: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.provider openai")}`, + "", + `Verify: ${formatCliCommand("openclaw memory status --deep")}`, + ].join("\n"), + "Memory search", + ); + return; + } + // Remote provider — check for API key + if (hasRemoteApiKey || (await hasApiKeyForProvider(resolved.provider, cfg, agentDir))) { + return; + } + const envVar = providerEnvVar(resolved.provider); + note( + [ + `Memory search provider is set to "${resolved.provider}" but no API key was found.`, + `Semantic recall will not work without a valid API key.`, + "", + "Fix (pick one):", + `- Set ${envVar} in your environment`, + `- Add credentials: ${formatCliCommand(`openclaw auth add --provider ${resolved.provider}`)}`, + `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, + "", + `Verify: ${formatCliCommand("openclaw memory status --deep")}`, + ].join("\n"), + "Memory search", + ); + return; + } + + // provider === "auto": check all providers in resolution order + if (hasLocalEmbeddings(resolved.local)) { + return; + } + for (const provider of ["openai", "gemini", "voyage"] as const) { + if (hasRemoteApiKey || (await hasApiKeyForProvider(provider, cfg, agentDir))) { + return; + } + } + + note( + [ + "Memory search is enabled but no embedding provider is configured.", + "Semantic recall will not work without an embedding provider.", + "", + "Fix (pick one):", + "- Set OPENAI_API_KEY or GEMINI_API_KEY in your environment", + `- Add credentials: ${formatCliCommand("openclaw auth add --provider openai")}`, + `- For local embeddings: configure agents.defaults.memorySearch.provider and local model path`, + `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, + "", + `Verify: ${formatCliCommand("openclaw memory status --deep")}`, + ].join("\n"), + "Memory search", + ); +} + +function hasLocalEmbeddings(local: { modelPath?: string }): boolean { + const modelPath = local.modelPath?.trim(); + if (!modelPath) { + return false; + } + // Remote/downloadable models (hf: or http:) aren't pre-resolved on disk, + // so we can't confirm availability without a network call. Treat as + // potentially available — the user configured it intentionally. + if (/^(hf:|https?:)/i.test(modelPath)) { + return true; + } + const resolved = resolveUserPath(modelPath); + try { + return fsSync.statSync(resolved).isFile(); + } catch { + return false; + } +} + +async function hasApiKeyForProvider( + provider: "openai" | "gemini" | "voyage", + cfg: OpenClawConfig, + agentDir: string, +): Promise { + // Map embedding provider names to model-auth provider names + const authProvider = provider === "gemini" ? "google" : provider; + try { + await resolveApiKeyForProvider({ provider: authProvider, cfg, agentDir }); + return true; + } catch { + return false; + } +} + +function providerEnvVar(provider: string): string { + switch (provider) { + case "openai": + return "OPENAI_API_KEY"; + case "gemini": + return "GEMINI_API_KEY"; + case "voyage": + return "VOYAGE_API_KEY"; + default: + return `${provider.toUpperCase()}_API_KEY`; + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4143d1186da..832dc2074fd 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -35,6 +35,7 @@ import { maybeScanExtraGatewayServices, } from "./doctor-gateway-services.js"; import { noteSourceInstallIssues } from "./doctor-install.js"; +import { noteMemorySearchHealth } from "./doctor-memory-search.js"; import { noteMacLaunchAgentOverrides, noteMacLaunchctlGatewayEnvOverrides, @@ -259,6 +260,7 @@ export async function doctorCommand( } noteWorkspaceStatus(cfg); + await noteMemorySearchHealth(cfg); // Check and fix shell completion await doctorShellCompletion(runtime, prompter, {