diff --git a/CHANGELOG.md b/CHANGELOG.md index 384762f9aa2..113ead71d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus. - Gateway/chat history: preserve oversized transcript turns as explicit omitted-message placeholders while avoiding large JSONL parse stalls. Thanks @Marvinthebored and @vincentkoc. - Gateway/models: keep read-only model-list responses on registry-compatible fallbacks and metadata defaults, so empty or minimal persisted model files do not hide built-ins or custom model capabilities. Thanks @Marvinthebored. +- CLI/doctor: load the configured memory-slot plugin when resolving memory diagnostics so bundled `memory-core` no longer triggers a false “no active memory plugin” warning on standalone `doctor` / `status` runs. Fixes #76367. Thanks @neeravmakwana. - Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206. - Heartbeats/Codex: stop sending the legacy `HEARTBEAT_OK` prompt instruction when heartbeat turns have the structured `heartbeat_respond` tool, while keeping the text sentinel for legacy automatic heartbeat replies. Thanks @pashpashpash. - Agent runtimes: fail explicit plugin runtime selections honestly when the requested harness is unavailable instead of silently falling back to the embedded PI runtime. Thanks @pashpashpash. diff --git a/src/plugins/memory-runtime.test.ts b/src/plugins/memory-runtime.test.ts index 64d2647eb46..0ae721becdc 100644 --- a/src/plugins/memory-runtime.test.ts +++ b/src/plugins/memory-runtime.test.ts @@ -4,6 +4,11 @@ const resolveRuntimePluginRegistryMock = vi.fn(); const getLoadedRuntimePluginRegistryMock = vi.fn(); +const ensureStandaloneRuntimePluginRegistryLoadedMock = vi.hoisted(() => + vi.fn< + typeof import("./runtime/standalone-runtime-registry-loader.js").ensureStandaloneRuntimePluginRegistryLoaded + >(), +); const applyPluginAutoEnableMock = vi.fn(); const getMemoryRuntimeMock = vi.fn(); @@ -30,6 +35,10 @@ vi.mock("./active-runtime-registry.js", () => ({ getLoadedRuntimePluginRegistry: getLoadedRuntimePluginRegistryMock, })); +vi.mock("./runtime/standalone-runtime-registry-loader.js", () => ({ + ensureStandaloneRuntimePluginRegistryLoaded: ensureStandaloneRuntimePluginRegistryLoadedMock, +})); + vi.mock("./memory-state.js", () => ({ getMemoryRuntime: () => getMemoryRuntimeMock(), })); @@ -61,12 +70,25 @@ function createMemoryRuntimeFixture() { }; } -function expectMemoryRuntimeLoaded(rawConfig: unknown, autoEnabledConfig: unknown) { +function expectMemoryRuntimeLoaded( + rawConfig: unknown, + autoEnabledConfig: unknown, + pluginIds: readonly string[] = ["memory-core"], +) { void rawConfig; void autoEnabledConfig; expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalledWith( expect.objectContaining({ - requiredPluginIds: ["memory-core"], + requiredPluginIds: pluginIds, + }), + ); + expect(ensureStandaloneRuntimePluginRegistryLoadedMock).toHaveBeenCalledWith( + expect.objectContaining({ + requiredPluginIds: pluginIds, + loadOptions: expect.objectContaining({ + onlyPluginIds: pluginIds, + workspaceDir: "/resolved-workspace", + }), }), ); } @@ -84,7 +106,10 @@ function setAutoEnabledMemoryRuntime() { changes: [], autoEnabledReasons: {}, }); - getMemoryRuntimeMock.mockReturnValueOnce(undefined).mockReturnValue(runtime); + getMemoryRuntimeMock + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValue(runtime); return { rawConfig, autoEnabledConfig, runtime }; } @@ -92,6 +117,7 @@ function expectNoMemoryRuntimeBootstrap() { expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); expect(getLoadedRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled(); } async function expectAutoEnabledMemoryRuntimeCase(params: { @@ -130,6 +156,7 @@ describe("memory runtime auto-enable loading", () => { } = await import("./memory-runtime.js")); resolveRuntimePluginRegistryMock.mockReset(); getLoadedRuntimePluginRegistryMock.mockReset(); + ensureStandaloneRuntimePluginRegistryLoadedMock.mockReset(); applyPluginAutoEnableMock.mockReset(); getMemoryRuntimeMock.mockReset(); resolveAgentWorkspaceDirMock.mockReset(); @@ -179,18 +206,17 @@ describe("memory runtime auto-enable loading", () => { changes: [], autoEnabledReasons: {}, }); - getMemoryRuntimeMock.mockReturnValueOnce(undefined).mockReturnValue(runtime); + getMemoryRuntimeMock + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValue(runtime); await getActiveMemorySearchManager({ cfg: rawConfig as never, agentId: "main", }); - expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalledWith( - expect.objectContaining({ - requiredPluginIds: ["memory-lancedb"], - }), - ); + expectMemoryRuntimeLoaded(rawConfig, rawConfig, ["memory-lancedb"]); }); it("does not fall back to broad plugin loading when the memory slot is disabled", async () => { @@ -218,6 +244,80 @@ describe("memory runtime auto-enable loading", () => { expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); expect(getLoadedRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + + it("does not standalone-load the memory plugin when plugins are globally disabled", async () => { + const rawConfig = { + plugins: { + enabled: false, + }, + }; + getMemoryRuntimeMock.mockReturnValue(undefined); + + await expect( + getActiveMemorySearchManager({ + cfg: rawConfig as never, + agentId: "main", + }), + ).resolves.toEqual({ manager: null, error: "memory plugin unavailable" }); + + expectNoMemoryRuntimeBootstrap(); + }); + + it.each([ + { + name: "denied", + plugins: { + deny: ["memory-core"], + slots: { + memory: "memory-core", + }, + }, + }, + { + name: "entry-disabled", + plugins: { + entries: { + "memory-core": { enabled: false }, + }, + slots: { + memory: "memory-core", + }, + }, + }, + ] as const)("does not standalone-load a $name memory slot plugin", async ({ plugins }) => { + getMemoryRuntimeMock.mockReturnValue(undefined); + + await expect( + getActiveMemorySearchManager({ + cfg: { plugins } as never, + agentId: "main", + }), + ).resolves.toEqual({ manager: null, error: "memory plugin unavailable" }); + + expectNoMemoryRuntimeBootstrap(); + }); + + it("does not standalone-load plugins when the memory runtime is already registered", () => { + const rawConfig = { + plugins: { + slots: { + memory: "memory-core", + }, + }, + }; + const runtime = createMemoryRuntimeFixture(); + getLoadedRuntimePluginRegistryMock.mockReturnValue({} as never); + getMemoryRuntimeMock.mockReturnValueOnce(undefined).mockReturnValue(runtime); + + resolveActiveMemoryBackendConfig({ + cfg: rawConfig as never, + agentId: "main", + }); + + expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalled(); + expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled(); }); it.each([ diff --git a/src/plugins/memory-runtime.ts b/src/plugins/memory-runtime.ts index 1c6bd41a3d8..de3b8a6d1cc 100644 --- a/src/plugins/memory-runtime.ts +++ b/src/plugins/memory-runtime.ts @@ -1,11 +1,31 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveUserPath } from "../utils.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { normalizePluginsConfig } from "./config-state.js"; import { getMemoryRuntime } from "./memory-state.js"; +import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js"; function resolveMemoryRuntimePluginIds(config: OpenClawConfig): string[] { - const memorySlot = normalizePluginsConfig(config.plugins).slots.memory; - return typeof memorySlot === "string" && memorySlot.trim().length > 0 ? [memorySlot] : []; + const plugins = normalizePluginsConfig(config.plugins); + const memorySlot = plugins.slots.memory; + if (!plugins.enabled || typeof memorySlot !== "string" || memorySlot.trim().length === 0) { + return []; + } + const pluginId = memorySlot.trim(); + if (plugins.deny.includes(pluginId) || plugins.entries[pluginId]?.enabled === false) { + return []; + } + return [pluginId]; +} + +function resolveMemoryRuntimeWorkspaceDir(cfg: OpenClawConfig): string | undefined { + const agentId = resolveDefaultAgentId(cfg); + const dir = resolveAgentWorkspaceDir(cfg, agentId); + if (typeof dir !== "string" || !dir.trim()) { + return undefined; + } + return resolveUserPath(dir); } function ensureMemoryRuntime(cfg?: OpenClawConfig) { @@ -18,6 +38,18 @@ function ensureMemoryRuntime(cfg?: OpenClawConfig) { return getMemoryRuntime(); } getLoadedRuntimePluginRegistry({ requiredPluginIds: onlyPluginIds }); + if (getMemoryRuntime()) { + return getMemoryRuntime(); + } + const workspaceDir = resolveMemoryRuntimeWorkspaceDir(cfg); + ensureStandaloneRuntimePluginRegistryLoaded({ + requiredPluginIds: onlyPluginIds, + loadOptions: { + config: cfg, + onlyPluginIds, + workspaceDir, + }, + }); return getMemoryRuntime(); }