mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 07:42:44 +00:00
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>
This commit is contained in:
87
src/commands/doctor-memory-search.test.ts
Normal file
87
src/commands/doctor-memory-search.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
src/commands/doctor-memory-search.ts
Normal file
139
src/commands/doctor-memory-search.ts
Normal file
@@ -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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
// 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
maybeScanExtraGatewayServices,
|
maybeScanExtraGatewayServices,
|
||||||
} from "./doctor-gateway-services.js";
|
} from "./doctor-gateway-services.js";
|
||||||
import { noteSourceInstallIssues } from "./doctor-install.js";
|
import { noteSourceInstallIssues } from "./doctor-install.js";
|
||||||
|
import { noteMemorySearchHealth } from "./doctor-memory-search.js";
|
||||||
import {
|
import {
|
||||||
noteMacLaunchAgentOverrides,
|
noteMacLaunchAgentOverrides,
|
||||||
noteMacLaunchctlGatewayEnvOverrides,
|
noteMacLaunchctlGatewayEnvOverrides,
|
||||||
@@ -259,6 +260,7 @@ export async function doctorCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
noteWorkspaceStatus(cfg);
|
noteWorkspaceStatus(cfg);
|
||||||
|
await noteMemorySearchHealth(cfg);
|
||||||
|
|
||||||
// Check and fix shell completion
|
// Check and fix shell completion
|
||||||
await doctorShellCompletion(runtime, prompter, {
|
await doctorShellCompletion(runtime, prompter, {
|
||||||
|
|||||||
Reference in New Issue
Block a user