mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:50:43 +00:00
fix: validate resolved context engine contracts (#63222)
Merged via squash.
Prepared head SHA: 5f3a15c670
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) thanks @100yenadmin
|
||||
|
||||
- Plugins/status: report the registered context-engine IDs in `plugins inspect` instead of the owning plugin ID, so non-matching engine IDs and multi-engine plugins are classified correctly. (#58766) thanks @zhuisDEV
|
||||
- Context engines: reject resolved plugin engines whose reported `info.id` does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev.
|
||||
## 2026.4.12
|
||||
|
||||
### Changes
|
||||
@@ -312,6 +313,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/models: preserve provider-qualified refs for OpenRouter catalog models whose ids already contain slashes so picker selections submit allowlist-compatible model refs instead of dropping the `openrouter/` prefix. (#63416) Thanks @sallyom.
|
||||
- Plugin SDK/command auth: split command status builders onto the lightweight `openclaw/plugin-sdk/command-status` subpath while preserving deprecated `command-auth` compatibility exports, so auth-only plugin imports no longer pull status/context warmup into CLI onboarding paths. (#63174) Thanks @hxy91819.
|
||||
- Wizard/plugin config: coerce integer-typed plugin config fields from interactive text input so integer schema values persist as numbers instead of failing validation. (#63346) Thanks @jalehman.
|
||||
- npm packaging: derive required root runtime mirrors from bundled plugin manifests and built root chunks, then install packed release tarballs without the repo `node_modules` so release checks catch missing plugin deps before publish.
|
||||
|
||||
## 2026.4.8
|
||||
|
||||
|
||||
@@ -140,16 +140,20 @@ class MockContextEngine implements ContextEngine {
|
||||
}
|
||||
|
||||
class LegacySessionKeyStrictEngine implements ContextEngine {
|
||||
readonly info: ContextEngineInfo = {
|
||||
id: "legacy-sessionkey-strict",
|
||||
name: "Legacy SessionKey Strict Engine",
|
||||
};
|
||||
readonly info: ContextEngineInfo;
|
||||
readonly ingestCalls: Array<Record<string, unknown>> = [];
|
||||
readonly assembleCalls: Array<Record<string, unknown>> = [];
|
||||
readonly compactCalls: Array<Record<string, unknown>> = [];
|
||||
readonly maintainCalls: Array<Record<string, unknown>> = [];
|
||||
readonly ingestedMessages: AgentMessage[] = [];
|
||||
|
||||
constructor(engineId = "legacy-sessionkey-strict") {
|
||||
this.info = {
|
||||
id: engineId,
|
||||
name: "Legacy SessionKey Strict Engine",
|
||||
};
|
||||
}
|
||||
|
||||
private rejectSessionKey(params: { sessionKey?: string }): void {
|
||||
if (Object.prototype.hasOwnProperty.call(params, "sessionKey")) {
|
||||
throw new Error("Unrecognized key(s) in object: 'sessionKey'");
|
||||
@@ -223,12 +227,17 @@ class LegacySessionKeyStrictEngine implements ContextEngine {
|
||||
}
|
||||
|
||||
class SessionKeyRuntimeErrorEngine implements ContextEngine {
|
||||
readonly info: ContextEngineInfo = {
|
||||
id: "sessionkey-runtime-error",
|
||||
name: "SessionKey Runtime Error Engine",
|
||||
};
|
||||
readonly info: ContextEngineInfo;
|
||||
assembleCalls = 0;
|
||||
constructor(private readonly errorMessage = "sessionKey lookup failed") {}
|
||||
constructor(
|
||||
engineId = "sessionkey-runtime-error",
|
||||
private readonly errorMessage = "sessionKey lookup failed",
|
||||
) {
|
||||
this.info = {
|
||||
id: engineId,
|
||||
name: "SessionKey Runtime Error Engine",
|
||||
};
|
||||
}
|
||||
|
||||
async ingest(_params: {
|
||||
sessionId: string;
|
||||
@@ -266,12 +275,16 @@ class SessionKeyRuntimeErrorEngine implements ContextEngine {
|
||||
}
|
||||
|
||||
class LegacyAssembleStrictEngine implements ContextEngine {
|
||||
readonly info: ContextEngineInfo = {
|
||||
id: "legacy-assemble-strict",
|
||||
name: "Legacy Assemble Strict Engine",
|
||||
};
|
||||
readonly info: ContextEngineInfo;
|
||||
readonly assembleCalls: Array<Record<string, unknown>> = [];
|
||||
|
||||
constructor(engineId = "legacy-assemble-strict") {
|
||||
this.info = {
|
||||
id: engineId,
|
||||
name: "Legacy Assemble Strict Engine",
|
||||
};
|
||||
}
|
||||
|
||||
async ingest(_params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
@@ -541,7 +554,7 @@ describe("Registry tests", () => {
|
||||
describe("Legacy sessionKey compatibility", () => {
|
||||
it("memoizes legacy mode after the first strict compatibility retry", async () => {
|
||||
const engineId = `legacy-sessionkey-${Date.now().toString(36)}`;
|
||||
const strictEngine = new LegacySessionKeyStrictEngine();
|
||||
const strictEngine = new LegacySessionKeyStrictEngine(engineId);
|
||||
registerContextEngine(engineId, () => strictEngine);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
@@ -567,7 +580,7 @@ describe("Legacy sessionKey compatibility", () => {
|
||||
|
||||
it("retries strict ingest once and ingests each message only once", async () => {
|
||||
const engineId = `legacy-sessionkey-ingest-${Date.now().toString(36)}`;
|
||||
const strictEngine = new LegacySessionKeyStrictEngine();
|
||||
const strictEngine = new LegacySessionKeyStrictEngine(engineId);
|
||||
registerContextEngine(engineId, () => strictEngine);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
@@ -594,7 +607,7 @@ describe("Legacy sessionKey compatibility", () => {
|
||||
|
||||
it("retries strict maintain once and memoizes legacy mode there too", async () => {
|
||||
const engineId = `legacy-sessionkey-maintain-${Date.now().toString(36)}`;
|
||||
const strictEngine = new LegacySessionKeyStrictEngine();
|
||||
const strictEngine = new LegacySessionKeyStrictEngine(engineId);
|
||||
registerContextEngine(engineId, () => strictEngine);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
@@ -612,7 +625,7 @@ describe("Legacy sessionKey compatibility", () => {
|
||||
|
||||
it("does not retry non-compat runtime errors", async () => {
|
||||
const engineId = `sessionkey-runtime-${Date.now().toString(36)}`;
|
||||
const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine();
|
||||
const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(engineId);
|
||||
registerContextEngine(engineId, () => runtimeErrorEngine);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
@@ -630,6 +643,7 @@ describe("Legacy sessionKey compatibility", () => {
|
||||
it("does not treat 'Unknown sessionKey' runtime failures as schema-compat errors", async () => {
|
||||
const engineId = `sessionkey-unknown-runtime-${Date.now().toString(36)}`;
|
||||
const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(
|
||||
engineId,
|
||||
'Unknown sessionKey "agent:main:missing"',
|
||||
);
|
||||
registerContextEngine(engineId, () => runtimeErrorEngine);
|
||||
@@ -709,6 +723,95 @@ describe("Invalid engine fallback", () => {
|
||||
expect(message).toContain("legacy");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects resolved engines that omit info metadata", async () => {
|
||||
const engineId = `invalid-info-${Date.now().toString(36)}`;
|
||||
registerContextEngine(
|
||||
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.`,
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const engineId = `mismatched-info-id-${Date.now().toString(36)}`;
|
||||
registerContextEngine(
|
||||
engineId,
|
||||
() =>
|
||||
({
|
||||
info: { id: "legacy", name: "Broken Engine" },
|
||||
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: info.id must match registered id "${engineId}".`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects resolved engines that omit required lifecycle methods", async () => {
|
||||
const engineId = `invalid-methods-${Date.now().toString(36)}`;
|
||||
registerContextEngine(
|
||||
engineId,
|
||||
() =>
|
||||
({
|
||||
info: { id: engineId, name: "Broken Engine" },
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
}) as unknown as ContextEngine,
|
||||
);
|
||||
|
||||
await expect(resolveContextEngine(configWithSlot(engineId))).rejects.toThrow(
|
||||
`Context engine "${engineId}" factory returned an invalid ContextEngine: missing assemble(), missing compact().`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -809,7 +912,7 @@ describe("assemble() prompt forwarding", () => {
|
||||
|
||||
it("retries strict legacy assemble without sessionKey and prompt", async () => {
|
||||
const engineId = `prompt-legacy-${Date.now().toString(36)}`;
|
||||
const strictEngine = new LegacyAssembleStrictEngine();
|
||||
const strictEngine = new LegacyAssembleStrictEngine(engineId);
|
||||
registerContextEngine(engineId, () => strictEngine);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
|
||||
@@ -395,6 +395,49 @@ export function listContextEngineIds(): string[] {
|
||||
return [...getContextEngineRegistryState().engines.keys()];
|
||||
}
|
||||
|
||||
function describeResolvedContextEngineContractError(
|
||||
engineId: string,
|
||||
engine: unknown,
|
||||
): string | null {
|
||||
if (!engine || typeof engine !== "object") {
|
||||
return `Context engine "${engineId}" factory returned ${JSON.stringify(engine)} instead of a ContextEngine object.`;
|
||||
}
|
||||
|
||||
const candidate = engine as Record<string, unknown>;
|
||||
const issues: string[] = [];
|
||||
const info = candidate.info;
|
||||
if (!info || typeof info !== "object") {
|
||||
issues.push("missing info");
|
||||
} else {
|
||||
const infoRecord = info as Record<string, unknown>;
|
||||
const infoId = typeof infoRecord.id === "string" ? infoRecord.id.trim() : "";
|
||||
if (!infoId) {
|
||||
issues.push("missing info.id");
|
||||
} else if (infoId !== engineId) {
|
||||
issues.push(`info.id must match registered id "${engineId}"`);
|
||||
}
|
||||
if (typeof infoRecord.name !== "string" || !infoRecord.name.trim()) {
|
||||
issues.push("missing info.name");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate.ingest !== "function") {
|
||||
issues.push("missing ingest()");
|
||||
}
|
||||
if (typeof candidate.assemble !== "function") {
|
||||
issues.push("missing assemble()");
|
||||
}
|
||||
if (typeof candidate.compact !== "function") {
|
||||
issues.push("missing compact()");
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `Context engine "${engineId}" factory returned an invalid ContextEngine: ${issues.join(", ")}.`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -423,5 +466,11 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
|
||||
);
|
||||
}
|
||||
|
||||
return wrapContextEngineWithSessionKeyCompat(await entry.factory());
|
||||
const engine = await entry.factory();
|
||||
const contractError = describeResolvedContextEngineContractError(engineId, engine);
|
||||
if (contractError) {
|
||||
throw new Error(contractError);
|
||||
}
|
||||
|
||||
return wrapContextEngineWithSessionKeyCompat(engine);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user