Files
openclaw/src/context-engine/registry.ts
Jari Mustonen d8a600f2ad context-engine: pass runtime context to ContextEngineFactory (#67243)
Merged via squash.

Prepared head SHA: 9aca6a5af1
Co-authored-by: jarimustonen <1272053+jarimustonen@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-28 18:21:14 -07:00

601 lines
19 KiB
TypeScript

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";
/**
* Runtime context passed to context engine factories during resolution.
* Provides config and path information so plugins can initialize engines
* without fragile workarounds.
*/
export type ContextEngineFactoryContext = {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
};
/**
* A factory that creates a ContextEngine instance.
* Supports async creation for engines that need DB connections etc.
*
* The factory receives a {@link ContextEngineFactoryContext} with runtime
* environment context (config, paths). Existing no-arg factories remain
* backward compatible because TypeScript permits assigning functions with
* fewer parameters to wider signatures.
*/
export type ContextEngineFactory = (
ctx: ContextEngineFactoryContext,
) => ContextEngine | Promise<ContextEngine>;
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
type RegisterContextEngineForOwnerOptions = {
allowSameOwnerRefresh?: boolean;
};
const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat");
const SESSION_KEY_COMPAT_METHODS = [
"bootstrap",
"maintain",
"ingest",
"ingestBatch",
"afterTurn",
"assemble",
"compact",
] as const;
const LEGACY_COMPAT_PARAMS = ["sessionKey", "prompt"] as const;
const LEGACY_COMPAT_METHOD_KEYS = {
bootstrap: ["sessionKey"],
maintain: ["sessionKey"],
ingest: ["sessionKey"],
ingestBatch: ["sessionKey"],
afterTurn: ["sessionKey"],
assemble: ["sessionKey", "prompt"],
compact: ["sessionKey"],
} as const;
type SessionKeyCompatMethodName = (typeof SESSION_KEY_COMPAT_METHODS)[number];
type SessionKeyCompatParams = {
sessionKey?: string;
prompt?: string;
};
type LegacyCompatKey = (typeof LEGACY_COMPAT_PARAMS)[number];
type LegacyCompatParamMap = Partial<Record<LegacyCompatKey, unknown>>;
function isSessionKeyCompatMethodName(value: PropertyKey): value is SessionKeyCompatMethodName {
return (
typeof value === "string" && (SESSION_KEY_COMPAT_METHODS as readonly string[]).includes(value)
);
}
function hasOwnLegacyCompatKey<K extends LegacyCompatKey>(
params: unknown,
key: K,
): params is SessionKeyCompatParams & Required<Pick<LegacyCompatParamMap, K>> {
return (
params !== null &&
typeof params === "object" &&
Object.prototype.hasOwnProperty.call(params, key)
);
}
function withoutLegacyCompatKeys<T extends SessionKeyCompatParams>(
params: T,
keys: Iterable<LegacyCompatKey>,
): T {
const legacyParams = { ...params };
for (const key of keys) {
delete legacyParams[key];
}
return legacyParams;
}
function issueRejectsLegacyCompatKeyStrictly(issue: unknown, key: LegacyCompatKey): boolean {
if (!issue || typeof issue !== "object") {
return false;
}
const issueRecord = issue as {
code?: unknown;
keys?: unknown;
message?: unknown;
};
if (
issueRecord.code === "unrecognized_keys" &&
Array.isArray(issueRecord.keys) &&
issueRecord.keys.some((issueKey) => issueKey === key)
) {
return true;
}
return isLegacyCompatErrorForKey(issueRecord.message, key);
}
function* iterateErrorChain(error: unknown) {
let current = error;
const seen = new Set<unknown>();
while (current !== undefined && current !== null && !seen.has(current)) {
yield current;
seen.add(current);
if (typeof current !== "object") {
break;
}
current = (current as { cause?: unknown }).cause;
}
}
const LEGACY_UNKNOWN_FIELD_PATTERNS: Record<LegacyCompatKey, readonly RegExp[]> = {
sessionKey: [
/\bunrecognized key(?:\(s\)|s)? in object:.*['"`]sessionKey['"`]/i,
/\badditional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i,
/\bmust not have additional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i,
/\b(?:unexpected|extraneous)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i,
/\b(?:unknown|invalid)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i,
/['"`]sessionKey['"`].*\b(?:was|is)\s+not allowed\b/i,
/"code"\s*:\s*"unrecognized_keys"[^]*"sessionKey"/i,
],
prompt: [
/\bunrecognized key(?:\(s\)|s)? in object:.*['"`]prompt['"`]/i,
/\badditional propert(?:y|ies)\b.*['"`]prompt['"`]/i,
/\bmust not have additional propert(?:y|ies)\b.*['"`]prompt['"`]/i,
/\b(?:unexpected|extraneous)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]prompt['"`]/i,
/\b(?:unknown|invalid)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]prompt['"`]/i,
/['"`]prompt['"`].*\b(?:was|is)\s+not allowed\b/i,
/"code"\s*:\s*"unrecognized_keys"[^]*"prompt"/i,
],
} as const;
function isLegacyCompatUnknownFieldValidationMessage(
message: string,
key: LegacyCompatKey,
): boolean {
return LEGACY_UNKNOWN_FIELD_PATTERNS[key].some((pattern) => pattern.test(message));
}
function isLegacyCompatErrorForKey(error: unknown, key: LegacyCompatKey): boolean {
for (const candidate of iterateErrorChain(error)) {
if (Array.isArray(candidate)) {
if (candidate.some((entry) => issueRejectsLegacyCompatKeyStrictly(entry, key))) {
return true;
}
continue;
}
if (typeof candidate === "string") {
if (isLegacyCompatUnknownFieldValidationMessage(candidate, key)) {
return true;
}
continue;
}
if (!candidate || typeof candidate !== "object") {
continue;
}
const issueContainer = candidate as {
message?: unknown;
issues?: unknown;
errors?: unknown;
};
if (
Array.isArray(issueContainer.issues) &&
issueContainer.issues.some((issue) => issueRejectsLegacyCompatKeyStrictly(issue, key))
) {
return true;
}
if (
Array.isArray(issueContainer.errors) &&
issueContainer.errors.some((issue) => issueRejectsLegacyCompatKeyStrictly(issue, key))
) {
return true;
}
if (
typeof issueContainer.message === "string" &&
isLegacyCompatUnknownFieldValidationMessage(issueContainer.message, key)
) {
return true;
}
}
return false;
}
function detectRejectedLegacyCompatKeys(
error: unknown,
allowedKeys: readonly LegacyCompatKey[],
): Set<LegacyCompatKey> {
const rejectedKeys = new Set<LegacyCompatKey>();
for (const key of allowedKeys) {
if (isLegacyCompatErrorForKey(error, key)) {
rejectedKeys.add(key);
}
}
return rejectedKeys;
}
async function invokeWithLegacyCompat<TResult, TParams extends SessionKeyCompatParams>(
method: (params: TParams) => Promise<TResult> | TResult,
params: TParams,
allowedKeys: readonly LegacyCompatKey[],
opts?: {
onLegacyModeDetected?: () => void;
onLegacyKeysDetected?: (keys: Set<LegacyCompatKey>) => void;
rejectedKeys?: ReadonlySet<LegacyCompatKey>;
},
): Promise<TResult> {
const activeRejectedKeys = new Set(opts?.rejectedKeys ?? []);
const availableKeys = allowedKeys.filter((key) => hasOwnLegacyCompatKey(params, key));
if (availableKeys.length === 0) {
return await method(params);
}
let currentParams =
activeRejectedKeys.size > 0 ? withoutLegacyCompatKeys(params, activeRejectedKeys) : params;
try {
return await method(currentParams);
} catch (error) {
let currentError = error;
while (true) {
const rejectedKeys = detectRejectedLegacyCompatKeys(currentError, availableKeys);
let learnedNewKey = false;
for (const key of rejectedKeys) {
if (!activeRejectedKeys.has(key)) {
activeRejectedKeys.add(key);
learnedNewKey = true;
}
}
if (!learnedNewKey) {
throw currentError;
}
opts?.onLegacyModeDetected?.();
opts?.onLegacyKeysDetected?.(rejectedKeys);
currentParams = withoutLegacyCompatKeys(params, activeRejectedKeys);
try {
return await method(currentParams);
} catch (retryError) {
currentError = retryError;
}
}
}
}
function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEngine {
const marked = engine as ContextEngine & {
[LEGACY_SESSION_KEY_COMPAT]?: boolean;
};
if (marked[LEGACY_SESSION_KEY_COMPAT]) {
return engine;
}
let isLegacy = false;
const rejectedKeys = new Set<LegacyCompatKey>();
const proxy: ContextEngine = new Proxy(engine, {
get(target, property, receiver) {
if (property === LEGACY_SESSION_KEY_COMPAT) {
return true;
}
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function") {
return value;
}
if (!isSessionKeyCompatMethodName(property)) {
return value.bind(target);
}
return (params: SessionKeyCompatParams) => {
const method = value.bind(target) as (params: SessionKeyCompatParams) => unknown;
const allowedKeys = LEGACY_COMPAT_METHOD_KEYS[property];
if (
isLegacy &&
allowedKeys.some((key) => rejectedKeys.has(key) && hasOwnLegacyCompatKey(params, key))
) {
return method(withoutLegacyCompatKeys(params, rejectedKeys));
}
return invokeWithLegacyCompat(method, params, allowedKeys, {
onLegacyModeDetected: () => {
isLegacy = true;
},
onLegacyKeysDetected: (keys) => {
for (const key of keys) {
rejectedKeys.add(key);
}
},
rejectedKeys,
});
};
},
});
return proxy;
}
// ---------------------------------------------------------------------------
// Registry (module-level singleton)
// ---------------------------------------------------------------------------
const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState");
const CORE_CONTEXT_ENGINE_OWNER = "core";
const PUBLIC_CONTEXT_ENGINE_OWNER = "public-sdk";
type ContextEngineRegistryState = {
engines: Map<
string,
{
factory: ContextEngineFactory;
owner: string;
}
>;
};
// Keep context-engine registrations process-global so duplicated dist chunks
// still share one registry map at runtime.
const contextEngineRegistryState = resolveGlobalSingleton<ContextEngineRegistryState>(
CONTEXT_ENGINE_REGISTRY_STATE,
() => ({
engines: new Map(),
}),
);
function getContextEngineRegistryState(): ContextEngineRegistryState {
return contextEngineRegistryState;
}
function requireContextEngineOwner(owner: string): string {
const normalizedOwner = owner.trim();
if (!normalizedOwner) {
throw new Error(
`registerContextEngineForOwner: owner must be a non-empty string, got ${JSON.stringify(owner)}`,
);
}
return normalizedOwner;
}
/**
* Register a context engine implementation under an explicit trusted owner.
*/
export function registerContextEngineForOwner(
id: string,
factory: ContextEngineFactory,
owner: string,
opts?: RegisterContextEngineForOwnerOptions,
): ContextEngineRegistrationResult {
const normalizedOwner = requireContextEngineOwner(owner);
const registry = getContextEngineRegistryState().engines;
const existing = registry.get(id);
if (
id === defaultSlotIdForKey("contextEngine") &&
normalizedOwner !== CORE_CONTEXT_ENGINE_OWNER
) {
return { ok: false, existingOwner: CORE_CONTEXT_ENGINE_OWNER };
}
if (existing && existing.owner !== normalizedOwner) {
return { ok: false, existingOwner: existing.owner };
}
if (existing && opts?.allowSameOwnerRefresh !== true) {
return { ok: false, existingOwner: existing.owner };
}
registry.set(id, { factory, owner: normalizedOwner });
return { ok: true };
}
/**
* Public SDK entry point for third-party registrations.
*
* This path is intentionally unprivileged: it cannot claim core-owned ids and
* it cannot safely refresh an existing registration because the caller's
* identity is not authenticated.
*/
export function registerContextEngine(
id: string,
factory: ContextEngineFactory,
): ContextEngineRegistrationResult {
return registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER);
}
/**
* Return the factory for a registered engine, or undefined.
*/
export function getContextEngineFactory(id: string): ContextEngineFactory | undefined {
return getContextEngineRegistryState().engines.get(id)?.factory;
}
/**
* List all registered engine ids.
*/
export function listContextEngineIds(): string[] {
return [...getContextEngineRegistryState().engines.keys()];
}
export function clearContextEnginesForOwner(owner: string): void {
const normalizedOwner = requireContextEngineOwner(owner);
const registry = getContextEngineRegistryState().engines;
for (const [id, entry] of registry.entries()) {
if (entry.owner === normalizedOwner) {
registry.delete(id);
}
}
}
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>;
// Engines own their internal info.id; it is metadata, not a handle into the
// registry. The registered id (plugin slot id) and the engine's own id are
// allowed to differ, so we only require that info.id is a non-empty string
// for display/logging purposes and do not enforce equality with engineId.
const infoId = typeof infoRecord.id === "string" ? infoRecord.id.trim() : "";
if (!infoId) {
issues.push("missing info.id");
}
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
// ---------------------------------------------------------------------------
/**
* Options for {@link resolveContextEngine}.
*/
export type ResolveContextEngineOptions = {
agentDir?: string;
workspaceDir?: string;
};
/**
* Resolve which ContextEngine to use based on plugin slot configuration.
*
* Resolution order:
* 1. `config.plugins.slots.contextEngine` (explicit slot override)
* 2. Default slot value ("legacy")
*
* When `config` is provided it is forwarded to the factory as part of a
* {@link ContextEngineFactoryContext}. Additional runtime paths can be
* supplied via `options`. Existing no-arg factories continue to work
* because JavaScript permits extra arguments at call sites.
*
* 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,
options?: ResolveContextEngineOptions,
): Promise<ContextEngine> {
const slotValue = config?.plugins?.slots?.contextEngine;
const engineId =
typeof slotValue === "string" && slotValue.trim()
? slotValue.trim()
: defaultSlotIdForKey("contextEngine");
const defaultEngineId = defaultSlotIdForKey("contextEngine");
const isDefaultEngine = engineId === defaultEngineId;
const factoryCtx: ContextEngineFactoryContext = {
config,
agentDir: options?.agentDir,
workspaceDir: options?.workspaceDir,
};
const entry = getContextEngineRegistryState().engines.get(engineId);
if (!entry) {
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, factoryCtx);
}
let engine: ContextEngine;
try {
engine = await entry.factory(factoryCtx);
} 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, factoryCtx);
}
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, factoryCtx);
}
if (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, factoryCtx);
}
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,
factoryCtx: ContextEngineFactoryContext,
): 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(factoryCtx);
const contractError = describeResolvedContextEngineContractError(defaultEngineId, engine);
if (contractError) {
throw new Error(`[context-engine] ${contractError}`);
}
return wrapContextEngineWithSessionKeyCompat(engine);
}