mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user