fix(context-engine): gracefully degrade to legacy engine on third-party plugin resolution failure (#66930)

Merged via squash.

Prepared head SHA: 969c67716c
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Reviewed-by: @openperf
This commit is contained in:
Chunyue Wang
2026-04-15 14:59:29 +08:00
committed by GitHub
parent 732db75279
commit 6aa4515798
3 changed files with 181 additions and 55 deletions

View File

@@ -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

View File

@@ -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, unknown>)[
Symbol.for("openclaw.contextEngineRegistryState")
] as { engines: Map<string, unknown> } | 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"),
);
});
});

View File

@@ -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<ContextEngine> {
const slotValue = config?.plugins?.slots?.contextEngine;
@@ -458,19 +461,85 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
? slotValue.trim()
: defaultSlotIdForKey("contextEngine");
const defaultEngineId = defaultSlotIdForKey("contextEngine");
const isDefaultEngine = engineId === defaultEngineId;
const entry = getContextEngineRegistryState().engines.get(engineId);
if (!entry) {
throw new Error(
`Context engine "${engineId}" is not registered. ` +
`Available engines: ${listContextEngineIds().join(", ") || "(none)"}`,
if (isDefaultEngine) {
throw new Error(
`Context engine "${engineId}" is not registered. ` +
`Available engines: ${listContextEngineIds().join(", ") || "(none)"}`,
);
}
console.error(
`[context-engine] Context engine "${sanitizeForLog(engineId)}" is not registered; ` +
`falling back to default engine "${defaultEngineId}".`,
);
return resolveDefaultContextEngine(defaultEngineId);
}
const engine = await entry.factory();
const contractError = describeResolvedContextEngineContractError(engineId, engine);
let engine: ContextEngine;
try {
engine = await entry.factory();
} catch (factoryError) {
if (isDefaultEngine) {
throw factoryError;
}
console.error(
`[context-engine] Context engine "${sanitizeForLog(engineId)}" factory threw during resolution: ` +
`${sanitizeForLog(factoryError instanceof Error ? factoryError.message : String(factoryError))}; ` +
`falling back to default engine "${defaultEngineId}".`,
);
return resolveDefaultContextEngine(defaultEngineId);
}
let contractError: string | null;
try {
contractError = describeResolvedContextEngineContractError(engineId, engine);
} catch (validationError) {
if (isDefaultEngine) {
throw validationError;
}
console.error(
`[context-engine] Context engine "${sanitizeForLog(engineId)}" contract validation threw: ` +
`${sanitizeForLog(validationError instanceof Error ? validationError.message : String(validationError))}; ` +
`falling back to default engine "${defaultEngineId}".`,
);
return resolveDefaultContextEngine(defaultEngineId);
}
if (contractError) {
throw new Error(contractError);
if (isDefaultEngine) {
throw new Error(contractError);
}
// contractError includes engineId from plugin config; sanitizeForLog covers it
console.error(
`[context-engine] ${sanitizeForLog(contractError)}; falling back to default engine "${defaultEngineId}".`,
);
return resolveDefaultContextEngine(defaultEngineId);
}
return wrapContextEngineWithSessionKeyCompat(engine);
}
/**
* Resolve the default context engine as a last-resort fallback.
*
* This helper is intentionally strict: if the default engine itself fails,
* there is no further fallback and the error must propagate.
*/
async function resolveDefaultContextEngine(defaultEngineId: string): Promise<ContextEngine> {
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);
}