diff --git a/extensions/llama-cpp/index.test.ts b/extensions/llama-cpp/index.test.ts index aa49ac8cc0d8..08c40011ba53 100644 --- a/extensions/llama-cpp/index.test.ts +++ b/extensions/llama-cpp/index.test.ts @@ -14,6 +14,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const memoryHostEmbeddingMocks = vi.hoisted(() => ({ createLocalEmbeddingProvider: vi.fn(), })); +const LOCAL_EMBEDDING_RUNTIME_FACTS = Symbol.for("openclaw.localEmbeddingRuntimeFacts"); vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({ createLocalEmbeddingProvider: memoryHostEmbeddingMocks.createLocalEmbeddingProvider, @@ -59,7 +60,13 @@ describe("llama.cpp provider plugin", () => { it("adapts the worker-backed local embedding provider", async () => { const close = vi.fn(); - memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({ + const getRuntimeFacts = vi.fn(() => ({ + engine: "llama.cpp" as const, + state: "ready" as const, + backend: "metal" as const, + buildType: "prebuilt" as const, + })); + const workerProvider = { id: "local", model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, maxInputTokens: 2048, @@ -67,7 +74,11 @@ describe("llama.cpp provider plugin", () => { embedBatchInputs: vi.fn(async () => [[0.3, 0.4]]), embedBatch: vi.fn(async () => [[1, 0]]), close, + }; + Object.defineProperty(workerProvider, LOCAL_EMBEDDING_RUNTIME_FACTS, { + value: getRuntimeFacts, }); + memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue(workerProvider); const abortController = new AbortController(); const result = await llamaCppEmbeddingProviderAdapter.create({ @@ -89,6 +100,20 @@ describe("llama.cpp provider plugin", () => { expect(provider.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL); expect(provider.maxInputTokens).toBe(2048); + const adaptedGetRuntimeFacts = Reflect.get(provider, LOCAL_EMBEDDING_RUNTIME_FACTS); + if (typeof adaptedGetRuntimeFacts !== "function") { + throw new Error("expected llama.cpp runtime facts carrier"); + } + expect(adaptedGetRuntimeFacts()).toEqual({ + engine: "llama.cpp", + state: "ready", + backend: "metal", + buildType: "prebuilt", + }); + expect(result.runtime?.cacheKeyData).toEqual({ + provider: "local", + model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, + }); expect(close).toHaveBeenCalledTimes(1); expect(memoryHostEmbeddingMocks.createLocalEmbeddingProvider).toHaveBeenCalledWith( { @@ -104,9 +129,9 @@ describe("llama.cpp provider plugin", () => { nodeLlamaCppImportUrl: expect.stringContaining("node-llama-cpp"), }, ); - const workerProvider = + const createdWorkerProvider = await memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mock.results[0].value; - expect(workerProvider.embedBatchInputs).toHaveBeenCalledWith([{ text: "doc" }], { + expect(createdWorkerProvider.embedBatchInputs).toHaveBeenCalledWith([{ text: "doc" }], { signal: abortController.signal, }); }); diff --git a/extensions/llama-cpp/src/embedding-provider.ts b/extensions/llama-cpp/src/embedding-provider.ts index 356911f42774..800fb3e1b709 100644 --- a/extensions/llama-cpp/src/embedding-provider.ts +++ b/extensions/llama-cpp/src/embedding-provider.ts @@ -28,6 +28,7 @@ type LlamaCppEmbeddingProviderRuntimeOptions = { }; const LLAMA_CPP_EMBEDDING_PROVIDER_ID = "local"; +const LOCAL_EMBEDDING_RUNTIME_FACTS = Symbol.for("openclaw.localEmbeddingRuntimeFacts"); export const DEFAULT_LLAMA_CPP_EMBEDDING_MODEL = "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf"; const DEFAULT_LLAMA_CPP_EMBEDDING_MODEL_CACHE_FILE_NAME = @@ -159,8 +160,18 @@ function resolveNodeLlamaCppImportUrl(): string { return pathToFileURL(requireFromPlugin.resolve("node-llama-cpp")).href; } +function copyLocalRuntimeFacts(source: object, target: object): void { + const getRuntimeFacts = Reflect.get(source, LOCAL_EMBEDDING_RUNTIME_FACTS); + if (typeof getRuntimeFacts === "function") { + Object.defineProperty(target, LOCAL_EMBEDDING_RUNTIME_FACTS, { + enumerable: false, + value: getRuntimeFacts, + }); + } +} + function adaptMemoryEmbeddingProvider(provider: MemoryEmbeddingProvider): EmbeddingProvider { - return { + const adapted: EmbeddingProvider = { id: LLAMA_CPP_EMBEDDING_PROVIDER_ID, model: provider.model, maxInputTokens: provider.maxInputTokens, @@ -180,6 +191,8 @@ function adaptMemoryEmbeddingProvider(provider: MemoryEmbeddingProvider): Embedd }, close: provider.close, }; + copyLocalRuntimeFacts(provider, adapted); + return adapted; } export async function createLlamaCppMemoryEmbeddingProvider( @@ -198,6 +211,9 @@ export async function createLlamaCppMemoryEmbeddingProvider( ); const identifiedProvider = identity.model === provider.model ? provider : { ...provider, model: identity.model }; + if (identifiedProvider !== provider) { + copyLocalRuntimeFacts(provider, identifiedProvider); + } return { provider: identifiedProvider, runtime: createLlamaCppEmbeddingProviderRuntime(identity), diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index 1ab95fb0c763..87574cca1d78 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -74,6 +74,36 @@ type MemoryManagerPurpose = Parameters[0]["purpos type MemorySourceName = "memory" | "sessions"; +type LlamaCppRuntimeStatus = { + state?: string; + backend?: string; + buildType?: string; + deviceNames?: string[]; + memory?: { + totalBytes: number; + usedBytes: number; + freeBytes: number; + unifiedBytes: number; + observedAtMs: number; + }; + offload?: { + supported: boolean; + offloadedLayers?: number; + totalLayers?: number; + }; + context?: { + requestedSize: number | "auto"; + }; + loadError?: string; +}; + +function readLlamaCppRuntimeStatus( + status: ReturnType, +): LlamaCppRuntimeStatus | null { + const runtime = asRecord(asRecord(status.custom)?.llamaCppRuntime); + return runtime?.engine === "llama.cpp" ? (runtime as LlamaCppRuntimeStatus) : null; +} + function formatMemoryIndexIdentityWarning( status: ReturnType, agentId: string, @@ -96,6 +126,20 @@ function formatMemoryIndexIdentityWarning( }; } +function formatRuntimeBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && value >= 1024; index += 1) { + value /= 1024; + unit = units[index]; + } + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`; +} + type SourceScan = { source: MemorySourceName; totalFiles: number | null; @@ -910,6 +954,43 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`); } } + const llamaCppRuntime = opts.deep ? readLlamaCppRuntimeStatus(status) : null; + if (llamaCppRuntime) { + const runtime = llamaCppRuntime; + const backend = runtime.backend ?? "unknown"; + const build = runtime.buildType ? ` (${runtime.buildType})` : ""; + lines.push(`${label("llama.cpp")} ${info(backend)}${muted(build)}`); + if (runtime.deviceNames?.length) { + lines.push(`${label("Devices")} ${info(runtime.deviceNames.join(", "))}`); + } + if (runtime.memory) { + const unified = + runtime.memory.unifiedBytes > 0 + ? ` · ${formatRuntimeBytes(runtime.memory.unifiedBytes)} unified` + : ""; + lines.push( + `${label("VRAM snapshot")} ${info(`${formatRuntimeBytes(runtime.memory.usedBytes)} used · ${formatRuntimeBytes(runtime.memory.freeBytes)} free · ${formatRuntimeBytes(runtime.memory.totalBytes)} total${unified}`)} ${muted(`(${new Date(runtime.memory.observedAtMs).toISOString()})`)}`, + ); + } + if (runtime.offload) { + const layers = + typeof runtime.offload.offloadedLayers === "number" && + typeof runtime.offload.totalLayers === "number" + ? `${runtime.offload.offloadedLayers}/${runtime.offload.totalLayers} layers` + : runtime.offload.supported + ? "supported" + : "unsupported"; + lines.push(`${label("GPU offload")} ${info(layers)}`); + } + if (runtime.context) { + lines.push( + `${label("Requested context")} ${info(`${runtime.context.requestedSize} tokens`)}`, + ); + } + if (runtime.loadError) { + lines.push(`${label("llama.cpp error")} ${warn(runtime.loadError)}`); + } + } const identityWarning = formatMemoryIndexIdentityWarning(status, agentId); if (identityWarning) { lines.push(`${label("Index identity")} ${warn(identityWarning.reason)}`); diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index 2f4cc0c3ca1e..900733db65b6 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -472,6 +472,14 @@ describe("memory cli", () => { provider: "auto", requestedProvider: "auto", vector: { enabled: true }, + custom: { + llamaCppRuntime: { + engine: "llama.cpp", + state: "ready", + backend: "metal", + buildType: "prebuilt", + }, + }, }), close, }); @@ -483,6 +491,7 @@ describe("memory cli", () => { expect(probeEmbeddingAvailability).not.toHaveBeenCalled(); expectLogged(log, "Provider: auto"); expectLogged(log, "Vector store: unknown"); + expectNotLogged(log, "llama.cpp:"); expect(close).toHaveBeenCalled(); }); @@ -589,7 +598,35 @@ describe("memory cli", () => { probeVectorStoreAvailability, probeVectorAvailability, probeEmbeddingAvailability, - status: () => makeMemoryStatus({ files: 1, chunks: 1 }), + status: () => + makeMemoryStatus({ + files: 1, + chunks: 1, + custom: { + llamaCppRuntime: { + engine: "llama.cpp", + state: "ready", + backend: "metal", + buildType: "prebuilt", + deviceNames: ["Apple M4 Max"], + memory: { + totalBytes: 64 * 1024 ** 3, + usedBytes: 8 * 1024 ** 3, + freeBytes: 56 * 1024 ** 3, + unifiedBytes: 64 * 1024 ** 3, + observedAtMs: Date.parse("2026-07-10T12:00:00.000Z"), + }, + offload: { + supported: true, + offloadedLayers: 20, + totalLayers: 24, + }, + context: { + requestedSize: 4096, + }, + }, + }, + }), close, }); @@ -600,6 +637,14 @@ describe("memory cli", () => { expect(probeVectorAvailability).toHaveBeenCalled(); expect(probeEmbeddingAvailability).toHaveBeenCalled(); expectLogged(log, "Embeddings: ready"); + expectLogged(log, "llama.cpp: metal (prebuilt)"); + expectLogged(log, "Devices: Apple M4 Max"); + expectLogged( + log, + "VRAM snapshot: 8.0 GB used · 56 GB free · 64 GB total · 64 GB unified (2026-07-10T12:00:00.000Z)", + ); + expectLogged(log, "GPU offload: 20/24 layers"); + expectLogged(log, "Requested context: 4096 tokens"); expect(close).toHaveBeenCalled(); }); diff --git a/extensions/memory-core/src/memory/embeddings.ts b/extensions/memory-core/src/memory/embeddings.ts index e74ef2a972c3..b1a2c9bf69ad 100644 --- a/extensions/memory-core/src/memory/embeddings.ts +++ b/extensions/memory-core/src/memory/embeddings.ts @@ -37,6 +37,7 @@ type CreateEmbeddingProviderOptions = MemoryEmbeddingProviderCreateOptions & { const DEFAULT_MEMORY_EMBEDDING_PROVIDER = "openai"; const LOCAL_LLAMA_CPP_PROVIDER_ID = "local"; +const LOCAL_EMBEDDING_RUNTIME_FACTS = Symbol.for("openclaw.localEmbeddingRuntimeFacts"); function createMissingLlamaCppProviderError(): Error { return new Error( @@ -52,7 +53,7 @@ function createMissingLlamaCppProviderError(): Error { function adaptGenericEmbeddingProvider( provider: GenericEmbeddingProvider, ): MemoryEmbeddingProvider { - return { + const adapted: MemoryEmbeddingProvider = { id: provider.id, model: provider.model, ...(typeof provider.maxInputTokens === "number" @@ -75,6 +76,14 @@ function adaptGenericEmbeddingProvider( }), ...(provider.close ? { close: provider.close } : {}), }; + const getRuntimeFacts = Reflect.get(provider, LOCAL_EMBEDDING_RUNTIME_FACTS); + if (typeof getRuntimeFacts === "function") { + Object.defineProperty(adapted, LOCAL_EMBEDDING_RUNTIME_FACTS, { + enumerable: false, + value: getRuntimeFacts, + }); + } + return adapted; } function adaptGenericRuntime( diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 8ca831593104..21869e5b1e89 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -2158,6 +2158,62 @@ describe("memory index", () => { } }); + it("exposes already-created local runtime facts without probing embeddings", async () => { + const cfg = createCfg({}); + const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js"); + const manager = await getRequiredMemoryIndexManager({ + cfg, + agentId: "main", + purpose: "status", + }); + try { + const getRuntimeFacts = vi.fn(() => ({ + engine: "llama.cpp" as const, + state: "ready" as const, + backend: "cuda" as const, + buildType: "prebuilt" as const, + deviceNames: ["NVIDIA Test GPU"], + offload: { + supported: true, + offloadedLayers: 24, + totalLayers: 24, + }, + context: { + requestedSize: 4096, + }, + })); + const provider = { + id: "local", + model: "test-model.gguf", + embedQuery: vi.fn(async () => [1, 0, 0, 0]), + embedBatch: vi.fn(async (texts: string[]) => texts.map(() => [1, 0, 0, 0])), + }; + Object.defineProperty(provider, Symbol.for("openclaw.localEmbeddingRuntimeFacts"), { + value: getRuntimeFacts, + }); + const fields = manager as unknown as { + provider: typeof provider | null; + }; + fields.provider = provider; + + expect(manager.status().custom?.llamaCppRuntime).toMatchObject({ + state: "ready", + backend: "cuda", + deviceNames: ["NVIDIA Test GPU"], + offload: { + offloadedLayers: 24, + totalLayers: 24, + }, + context: { + requestedSize: 4096, + }, + }); + expect(getRuntimeFacts).toHaveBeenCalledTimes(1); + } finally { + await manager.close?.(); + } + }); + it("keeps metadata after unchanged in-place force reindex", async () => { const cfg = createCfg({}); const manager = await getFreshManager(cfg); diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 0265bf4b813d..fc0737ed29db 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -69,6 +69,17 @@ import { type MemoryReadonlyRecoveryState, } from "./manager-sync-control.js"; import { applyTemporalDecayToHybridResults } from "./temporal-decay.js"; + +const LOCAL_EMBEDDING_RUNTIME_FACTS = Symbol.for("openclaw.localEmbeddingRuntimeFacts"); + +function getLocalEmbeddingRuntimeFacts(provider: EmbeddingProvider | null): unknown { + if (!provider) { + return undefined; + } + const getRuntimeFacts = Reflect.get(provider, LOCAL_EMBEDDING_RUNTIME_FACTS); + return typeof getRuntimeFacts === "function" ? getRuntimeFacts() : undefined; +} + const SNIPPET_MAX_CHARS = 700; const VECTOR_TABLE = MEMORY_INDEX_VECTOR_TABLE; const FTS_TABLE = MEMORY_INDEX_FTS_TABLE; @@ -1206,6 +1217,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem lastProvider: this.batchFailureLastProvider, }, custom: { + llamaCppRuntime: getLocalEmbeddingRuntimeFacts(this.provider), searchMode: providerInfo.searchMode, providerState: this.providerLifecycle, providerUnavailableReason: this.providerUnavailableReason, diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts index a1dda8156c19..465579354560 100644 --- a/src/commands/doctor-gateway-health.test.ts +++ b/src/commands/doctor-gateway-health.test.ts @@ -233,6 +233,28 @@ describe("probeGatewayMemoryStatus", () => { }); }); + it("carries last-known llama.cpp facts from the gateway", async () => { + callGateway.mockResolvedValue({ + embedding: { ok: true }, + embeddingRuntime: { + engine: "llama.cpp", + state: "ready", + backend: "metal", + buildType: "prebuilt", + }, + }); + + await expect(probeGatewayMemoryStatus({ cfg })).resolves.toMatchObject({ + checked: true, + ready: true, + runtimeFacts: { + state: "ready", + backend: "metal", + buildType: "prebuilt", + }, + }); + }); + it("treats outer gateway timeouts as inconclusive (skipped: false)", async () => { // A transport timeout must NOT be treated as a skipped probe. It is a real // diagnostic signal and the renderer should warn for key-optional providers. diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts index 8716d59100a3..401b3a0ba64e 100644 --- a/src/commands/doctor-gateway-health.ts +++ b/src/commands/doctor-gateway-health.ts @@ -9,7 +9,10 @@ import { isGatewayCredentialsRequiredError, } from "../gateway/call.js"; import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js"; -import type { DoctorMemoryStatusPayload } from "../gateway/server-methods/doctor.js"; +import type { + DoctorMemoryEmbeddingRuntimePayload, + DoctorMemoryStatusPayload, +} from "../gateway/server-methods/doctor.js"; import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -26,6 +29,7 @@ type GatewayMemoryProbe = { checked: boolean; ready: boolean; error?: string; + runtimeFacts?: DoctorMemoryEmbeddingRuntimePayload; /** * True when the probe was intentionally skipped by the gateway (probe: false * path). Distinct from checked: false caused by a network timeout or @@ -170,6 +174,7 @@ export async function probeGatewayMemoryStatus(params: { checked: gatewayChecked, ready: payload.embedding.ok, error: payload.embedding.error, + ...(payload.embeddingRuntime ? { runtimeFacts: payload.embeddingRuntime } : {}), skipped: !gatewayChecked, }; } catch (err) { diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index ca7ce92812b3..846f950c0600 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -266,6 +266,46 @@ describe("noteMemorySearchHealth", () => { expect(note).not.toHaveBeenCalled(); }); + it("reports last-known llama.cpp runtime facts from the gateway", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "local", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg, { + gatewayMemoryProbe: { + checked: true, + ready: true, + runtimeFacts: { + engine: "llama.cpp", + state: "ready", + backend: "cuda", + buildType: "prebuilt", + deviceNames: ["NVIDIA Test GPU"], + offload: { + supported: true, + offloadedLayers: 24, + totalLayers: 24, + }, + context: { + requestedSize: 4096, + }, + }, + }, + }); + + expect(note).toHaveBeenCalledWith( + [ + "llama.cpp runtime: cuda, prebuilt", + "Devices: NVIDIA Test GPU", + "GPU offload: 24/24 layers", + "Requested context: 4096 tokens", + ].join("\n"), + "Memory search", + ); + }); + it("does not warn when local provider readiness probe was intentionally skipped", async () => { resolveMemorySearchConfig.mockReturnValue({ provider: "local", diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 7ffa0f618d52..fcb141f1c64d 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -23,6 +23,7 @@ import { } from "../agents/model-auth.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { DoctorMemoryEmbeddingRuntimePayload } from "../gateway/server-methods/doctor.js"; import { formatErrorMessage } from "../infra/errors.js"; import { checkQmdBinaryAvailability, @@ -63,6 +64,19 @@ type MemoryEmbeddingProviderDoctorMetadata = { autoSelectPriority?: number; }; +function formatLocalRuntimeDoctorNote(facts: DoctorMemoryEmbeddingRuntimePayload): string { + const backend = facts.backend ?? "unknown"; + const build = facts.buildType ? `, ${facts.buildType}` : ""; + const devices = facts.deviceNames?.length ? `\nDevices: ${facts.deviceNames.join(", ")}` : ""; + const offload = + typeof facts.offload?.offloadedLayers === "number" && + typeof facts.offload.totalLayers === "number" + ? `\nGPU offload: ${facts.offload.offloadedLayers}/${facts.offload.totalLayers} layers` + : ""; + const context = facts.context ? `\nRequested context: ${facts.context.requestedSize} tokens` : ""; + return `llama.cpp runtime: ${backend}${build}${devices}${offload}${context}`; +} + const BUNDLED_MEMORY_EMBEDDING_PROVIDER_DOCTOR_METADATA: MemoryEmbeddingProviderDoctorMetadata[] = [ { providerId: "github-copilot", @@ -419,6 +433,7 @@ export async function noteMemorySearchHealth( ready: boolean; error?: string; skipped?: boolean; + runtimeFacts?: DoctorMemoryEmbeddingRuntimePayload; }; noteFn?: typeof note; includeWorkspaceMemoryHealth?: boolean; @@ -514,6 +529,9 @@ export async function noteMemorySearchHealth( if (provider === "local") { const suggestedRemoteProvider = resolveSuggestedRemoteMemoryProvider(); + if (opts?.gatewayMemoryProbe?.runtimeFacts?.state === "ready") { + noteFn(formatLocalRuntimeDoctorNote(opts.gatewayMemoryProbe.runtimeFacts), "Memory search"); + } if (opts?.gatewayMemoryProbe?.checked && opts.gatewayMemoryProbe.ready) { return; } diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 2895d4134261..1c6150908f47 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -322,6 +322,61 @@ describe("doctor.memory.status", () => { }); }); + it("returns llama.cpp runtime facts created by the deep embedding probe", async () => { + const close = vi.fn().mockResolvedValue(undefined); + let probed = false; + getMemorySearchManager.mockResolvedValue({ + manager: { + status: () => ({ + provider: "local", + ...(probed + ? { + custom: { + llamaCppRuntime: { + engine: "llama.cpp", + state: "ready", + backend: "cuda", + buildType: "prebuilt", + deviceNames: ["NVIDIA Test GPU"], + offload: { + supported: true, + offloadedLayers: 24, + totalLayers: 24, + }, + context: { + requestedSize: 4096, + }, + }, + }, + } + : {}), + }), + probeEmbeddingAvailability: vi.fn(async () => { + probed = true; + return { ok: true }; + }), + close, + }, + }); + const respond = vi.fn(); + + await invokeDoctorMemoryStatus(respond, { params: { probe: true } }); + + expect(respondPayload(respond).embeddingRuntime).toMatchObject({ + state: "ready", + backend: "cuda", + deviceNames: ["NVIDIA Test GPU"], + offload: { + offloadedLayers: 24, + totalLayers: 24, + }, + context: { + requestedSize: 4096, + }, + }); + expect(close).toHaveBeenCalled(); + }); + it("does not live-probe embedding readiness by default", async () => { const close = vi.fn().mockResolvedValue(undefined); const probeEmbeddingAvailability = vi.fn().mockResolvedValue({ ok: true }); diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index e56373b9b0f5..252febbaeb42 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -124,9 +124,34 @@ export type DoctorMemoryStatusPayload = { checkedAtMs?: number; cacheExpiresAtMs?: number; }; + embeddingRuntime?: DoctorMemoryEmbeddingRuntimePayload; dreaming?: DoctorMemoryDreamingPayload; }; +export type DoctorMemoryEmbeddingRuntimePayload = { + engine: "llama.cpp"; + state: "ready" | "failed"; + backend?: "metal" | "cuda" | "vulkan" | "cpu"; + buildType?: "localBuild" | "prebuilt"; + deviceNames?: string[]; + memory?: { + totalBytes: number; + usedBytes: number; + freeBytes: number; + unifiedBytes: number; + observedAtMs: number; + }; + offload?: { + supported: boolean; + offloadedLayers?: number; + totalLayers?: number; + }; + context?: { + requestedSize: number | "auto"; + }; + loadError?: string; +}; + export type DoctorMemoryDreamDiaryPayload = { agentId: string; found: boolean; @@ -727,11 +752,14 @@ export const doctorHandlers: GatewayRequestHandlers = { } try { - const status = manager.status(); + let status = manager.status(); const shouldProbe = shouldProbeMemoryEmbeddings(params); let embedding = shouldProbe ? await manager.probeEmbeddingAvailability() : (manager.getCachedEmbeddingAvailability?.() ?? SKIPPED_MEMORY_EMBEDDING_PROBE); + if (shouldProbe) { + status = manager.status(); + } if (!embedding.ok && !embedding.error) { embedding = { ok: false, error: "memory embeddings unavailable" }; } @@ -774,6 +802,12 @@ export const doctorHandlers: GatewayRequestHandlers = { agentId, provider: status.provider, embedding, + embeddingRuntime: (() => { + const runtime = asOptionalRecord(asOptionalRecord(status.custom)?.llamaCppRuntime); + return runtime?.engine === "llama.cpp" + ? (runtime as DoctorMemoryEmbeddingRuntimePayload) + : undefined; + })(), dreaming: { ...dreamingConfig, ...storeStats,