diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf420bf540..5ff786059ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - QQBot/cron: guard against undefined `event.content` in `parseFaceTags` and `filterInternalMarkers` so cron-triggered agent turns with no content payload no longer crash with `TypeError: Cannot read properties of undefined (reading 'startsWith')`. (#66302) Thanks @xinmotlanthua. - CLI/plugins: stop `--dangerously-force-unsafe-install` plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819. - Claude CLI/sessions: classify `No conversation found with session ID` as `session_expired` so expired CLI-backed conversations clear the stale binding and recover on the next turn. (#65028) thanks @Ivan-Fn. +- Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf. ## 2026.4.14 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index d3434d3104f..d99ade872f1 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { clearMemoryPluginState, registerMemoryPromptSection } from "../plugins/memory-state.js"; @@ -705,26 +705,88 @@ describe("Default engine selection", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Invalid engine fallback", () => { - it("includes the requested id and available ids in unknown-engine errors", async () => { - // Ensure at least legacy is registered so we see it in the available list + beforeEach(() => { registerLegacyContextEngine(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("falls back to default engine when requested engine is not registered", async () => { + const engine = await resolveContextEngine(configWithSlot("does-not-exist")); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("does-not-exist")); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("falling back to default engine"), + ); + }); + + it("throws when the default engine itself is not registered", async () => { + // Access the process-global registry via the well-known symbol and clear it + // so even the default engine is missing. The symbol key must match the + // private CONTEXT_ENGINE_REGISTRY_STATE constant in registry.ts — guard + // against a silent key mismatch so a rename surfaces loudly. + const registryState = (globalThis as Record)[ + Symbol.for("openclaw.contextEngineRegistryState") + ] as { engines: Map } | undefined; + expect(registryState).toBeDefined(); + const snapshot = new Map(registryState!.engines); + registryState!.engines.clear(); try { - await resolveContextEngine(configWithSlot("does-not-exist")); - // Should not reach here - expect.unreachable("Expected resolveContextEngine to throw"); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - expect(message).toContain("does-not-exist"); - expect(message).toContain("not registered"); - // Should mention available engines - expect(message).toMatch(/Available engines:/); - // At least "legacy" should be listed as available - expect(message).toContain("legacy"); + await expect(resolveContextEngine()).rejects.toThrow("not registered"); + } finally { + for (const [key, value] of snapshot) { + registryState!.engines.set(key, value); + } } }); - it("rejects resolved engines that omit info metadata", async () => { + it("propagates error when default engine factory throws", async () => { + // Override the default "legacy" engine with a throwing factory via the + // core-owner path so the registration is accepted. + registerContextEngineForOwner( + "legacy", + () => { + throw new Error("default engine init failed"); + }, + "core", + { allowSameOwnerRefresh: true }, + ); + + await expect(resolveContextEngine()).rejects.toThrow("default engine init failed"); + }); + + it("propagates error when default engine fails contract validation", async () => { + registerContextEngineForOwner( + "legacy", + () => ({ broken: true }) as unknown as ContextEngine, + "core", + { allowSameOwnerRefresh: true }, + ); + + await expect(resolveContextEngine()).rejects.toThrow( + 'Context engine "legacy" factory returned an invalid ContextEngine', + ); + }); + + it("falls back to default engine when factory throws", async () => { + const engineId = `factory-throw-${Date.now().toString(36)}`; + registerContextEngine(engineId, () => { + throw new Error("plugin version mismatch"); + }); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("plugin version mismatch")); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("falling back to default engine"), + ); + }); + + it("falls back to default engine when resolved engine omits info metadata", async () => { const engineId = `invalid-info-${Date.now().toString(36)}`; registerContextEngine( engineId, @@ -742,36 +804,12 @@ describe("Invalid engine fallback", () => { }) as unknown as ContextEngine, ); - await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( - `Context engine "${engineId}" factory returned an invalid ContextEngine: missing info.`, - ); + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("missing info")); }); - it("rejects resolved engines that omit required info fields", async () => { - const engineId = `invalid-info-fields-${Date.now().toString(36)}`; - registerContextEngine( - engineId, - () => - ({ - info: { id: engineId }, - async ingest() { - return { ingested: false }; - }, - async assemble({ messages }: { messages: AgentMessage[] }) { - return { messages, estimatedTokens: 0 }; - }, - async compact() { - return { ok: true, compacted: false }; - }, - }) as unknown as ContextEngine, - ); - - await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( - `Context engine "${engineId}" factory returned an invalid ContextEngine: missing info.name.`, - ); - }); - - it("rejects resolved engines whose info.id mismatches the registered id", async () => { + it("falls back to default engine when info.id mismatches the registered id", async () => { const engineId = `mismatched-info-id-${Date.now().toString(36)}`; registerContextEngine( engineId, @@ -790,12 +828,14 @@ describe("Invalid engine fallback", () => { }) as unknown as ContextEngine, ); - await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( - `Context engine "${engineId}" factory returned an invalid ContextEngine: info.id must match registered id "${engineId}".`, + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining(`info.id must match registered id "${engineId}"`), ); }); - it("rejects resolved engines that omit required lifecycle methods", async () => { + it("falls back to default engine when resolved engine omits lifecycle methods", async () => { const engineId = `invalid-methods-${Date.now().toString(36)}`; registerContextEngine( engineId, @@ -808,8 +848,24 @@ describe("Invalid engine fallback", () => { }) as unknown as ContextEngine, ); - await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow( - `Context engine "${engineId}" factory returned an invalid ContextEngine: missing assemble(), missing compact().`, + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("missing assemble(), missing compact()"), + ); + }); + + it("falls back to default engine when contract validation itself throws", async () => { + const engineId = `validation-throw-${Date.now().toString(36)}`; + // BigInt cannot be JSON.stringify'd — triggers a throw inside + // describeResolvedContextEngineContractError when the factory returns + // a non-object value that passes the typeof !== "object" branch. + registerContextEngine(engineId, () => 42n as unknown as ContextEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe("legacy"); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("contract validation threw"), ); }); }); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index f2d65373f9f..4979dfdfb69 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; import type { ContextEngine } from "./types.js"; /** @@ -449,7 +450,9 @@ function describeResolvedContextEngineContractError( * 1. `config.plugins.slots.contextEngine` (explicit slot override) * 2. Default slot value ("legacy") * - * Throws if the resolved engine id has no registered factory. + * Non-default engines that fail (unregistered, factory throw, or contract + * violation) are logged and silently replaced by the default engine. + * Throws only when the default engine itself cannot be resolved. */ export async function resolveContextEngine(config?: OpenClawConfig): Promise { const slotValue = config?.plugins?.slots?.contextEngine; @@ -458,19 +461,85 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise { + const defaultEntry = getContextEngineRegistryState().engines.get(defaultEngineId); + if (!defaultEntry) { + throw new Error( + `[context-engine] fallback failed: default engine "${defaultEngineId}" is not registered. ` + + `Available engines: ${listContextEngineIds().join(", ") || "(none)"}`, + ); + } + const engine = await defaultEntry.factory(); + const contractError = describeResolvedContextEngineContractError(defaultEngineId, engine); + if (contractError) { + throw new Error(`[context-engine] ${contractError}`); + } + return wrapContextEngineWithSessionKeyCompat(engine); +}