UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

539 lines (538 loc) 21.5 kB
import { t as sanitizeForLog } from "./ansi-BI9w76Cm.js"; import { n as resolveGlobalSingleton } from "./global-singleton-PwlQSEal.js"; import { n as defaultSlotIdForKey } from "./slots-kpL659LX.js"; //#region src/context-engine/registry.ts const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat"); const RESOLVED_CONTEXT_ENGINE_METADATA = /* @__PURE__ */ new WeakMap(); const RUNTIME_QUARANTINE_PROXY_STATE = /* @__PURE__ */ new WeakMap(); const SESSION_KEY_COMPAT_METHODS = [ "bootstrap", "maintain", "ingest", "ingestBatch", "afterTurn", "assemble", "compact" ]; const LEGACY_COMPAT_METHOD_KEYS = { bootstrap: ["sessionKey"], maintain: ["sessionKey"], ingest: ["sessionKey"], ingestBatch: ["sessionKey"], afterTurn: ["sessionKey"], assemble: ["sessionKey", "prompt"], compact: ["sessionKey"] }; function isSessionKeyCompatMethodName(value) { return typeof value === "string" && SESSION_KEY_COMPAT_METHODS.includes(value); } function hasOwnLegacyCompatKey(params, key) { return params !== null && typeof params === "object" && Object.hasOwn(params, key); } function withoutLegacyCompatKeys(params, keys) { const legacyParams = { ...params }; for (const key of keys) delete legacyParams[key]; return legacyParams; } function issueRejectsLegacyCompatKeyStrictly(issue, key) { if (!issue || typeof issue !== "object") return false; const issueRecord = issue; 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) { let current = error; const seen = /* @__PURE__ */ new Set(); while (current !== void 0 && current !== null && !seen.has(current)) { yield current; seen.add(current); if (typeof current !== "object") break; current = current.cause; } } const LEGACY_UNKNOWN_FIELD_PATTERNS = { 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 ] }; function isLegacyCompatUnknownFieldValidationMessage(message, key) { return LEGACY_UNKNOWN_FIELD_PATTERNS[key].some((pattern) => pattern.test(message)); } function isLegacyCompatErrorForKey(error, key) { 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; 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, allowedKeys) { const rejectedKeys = /* @__PURE__ */ new Set(); for (const key of allowedKeys) if (isLegacyCompatErrorForKey(error, key)) rejectedKeys.add(key); return rejectedKeys; } async function invokeWithLegacyCompat(method, params, allowedKeys, opts) { 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) { if (engine[LEGACY_SESSION_KEY_COMPAT]) return engine; let isLegacy = false; const rejectedKeys = /* @__PURE__ */ new Set(); return 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) => { const method = value.bind(target); 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 }); }; } }); } function wrapResolvedContextEngine(engine, metadata) { const compatWrapped = wrapContextEngineWithSessionKeyCompat(engine); const wrapped = metadata.defaultEngineId && metadata.factoryCtx && metadata.engineId !== metadata.defaultEngineId ? wrapContextEngineWithRuntimeQuarantine({ engine: compatWrapped, engineId: metadata.engineId, owner: metadata.owner, defaultEngineId: metadata.defaultEngineId, factoryCtx: metadata.factoryCtx }) : compatWrapped; RESOLVED_CONTEXT_ENGINE_METADATA.set(wrapped, metadata); return wrapped; } const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState"); const CORE_CONTEXT_ENGINE_OWNER = "core"; const PUBLIC_CONTEXT_ENGINE_OWNER = "public-sdk"; const contextEngineRegistryState = resolveGlobalSingleton(CONTEXT_ENGINE_REGISTRY_STATE, () => ({ engines: /* @__PURE__ */ new Map(), quarantinedEngines: /* @__PURE__ */ new Map() })); function getContextEngineRegistryState() { return contextEngineRegistryState; } function requireContextEngineOwner(owner) { const normalizedOwner = owner.trim(); if (!normalizedOwner) throw new Error(`registerContextEngineForOwner: owner must be a non-empty string, got ${JSON.stringify(owner)}`); return normalizedOwner; } function formatContextEngineError(error) { return error instanceof Error ? error.message : String(error); } function recordContextEngineQuarantine(params) { const registryState = getContextEngineRegistryState(); const existing = registryState.quarantinedEngines.get(params.engineId); if (existing) return existing; const quarantine = { engineId: params.engineId, operation: params.operation, reason: formatContextEngineError(params.error), failedAt: /* @__PURE__ */ new Date(), ...params.owner ? { owner: params.owner } : {} }; registryState.quarantinedEngines.set(params.engineId, quarantine); const ownerSuffix = params.owner ? ` owner=${sanitizeForLog(params.owner)}` : ""; console.error(`[context-engine] Context engine "${sanitizeForLog(params.engineId)}"${ownerSuffix} failed during ${sanitizeForLog(params.operation)}: ${sanitizeForLog(quarantine.reason)}; quarantining it for this process and falling back to default engine "${params.defaultEngineId}".`); return quarantine; } function getContextEngineQuarantine(engineId) { return getContextEngineRegistryState().quarantinedEngines.get(engineId); } function listContextEngineQuarantines() { const quarantines = []; for (const entry of getContextEngineRegistryState().quarantinedEngines.values()) { const quarantine = { engineId: entry.engineId, operation: entry.operation, reason: entry.reason, failedAt: new Date(entry.failedAt) }; if (entry.owner) quarantine.owner = entry.owner; quarantines.push(quarantine); } return quarantines; } function clearContextEngineRuntimeQuarantine(engineId) { const quarantinedEngines = getContextEngineRegistryState().quarantinedEngines; if (engineId === void 0) { quarantinedEngines.clear(); return; } quarantinedEngines.delete(engineId); } /** * Register a context engine implementation under an explicit trusted owner. */ function registerContextEngineForOwner(id, factory, owner, opts) { 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 }); clearContextEngineRuntimeQuarantine(id); 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. */ function registerContextEngine(id, factory) { return registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER); } /** * Return the factory for a registered engine, or undefined. */ function getContextEngineFactory(id) { return getContextEngineRegistryState().engines.get(id)?.factory; } /** * List all registered engine ids. */ function listContextEngineIds() { return [...getContextEngineRegistryState().engines.keys()]; } function clearContextEnginesForOwner(owner) { const normalizedOwner = requireContextEngineOwner(owner); const registryState = getContextEngineRegistryState(); const registry = registryState.engines; for (const [id, entry] of registry.entries()) if (entry.owner === normalizedOwner) { registry.delete(id); registryState.quarantinedEngines.delete(id); } } /** * Return the trusted plugin id that registered a resolved context engine. */ function resolveContextEngineOwnerPluginId(engine) { if (!engine) return; const owner = resolveEffectiveContextEngineMetadata(engine)?.owner; if (!owner?.startsWith("plugin:")) return; return owner.slice(7).trim() || void 0; } function resolveEffectiveContextEngineMetadata(engine) { const quarantineState = RUNTIME_QUARANTINE_PROXY_STATE.get(engine); if (quarantineState && getContextEngineQuarantine(quarantineState.engineId)) { const fallbackEngine = quarantineState.getResolvedFallbackEngine(); return (fallbackEngine ? RESOLVED_CONTEXT_ENGINE_METADATA.get(fallbackEngine) : void 0) ?? { owner: CORE_CONTEXT_ENGINE_OWNER }; } return RESOLVED_CONTEXT_ENGINE_METADATA.get(engine); } function describeResolvedContextEngineContractError(engineId, engine) { if (!engine || typeof engine !== "object") return `Context engine "${engineId}" factory returned ${JSON.stringify(engine)} instead of a ContextEngine object.`; const candidate = engine; const issues = []; const info = candidate.info; if (!info || typeof info !== "object") issues.push("missing info"); else { const infoRecord = info; if (!(typeof infoRecord.id === "string" ? infoRecord.id.trim() : "")) 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(", ")}.`; } const GUARDED_CONTEXT_ENGINE_METHODS = new Set([ "bootstrap", "maintain", "ingest", "ingestBatch", "afterTurn", "assemble", "compact", "prepareSubagentSpawn", "onSubagentEnded" ]); function contextEngineFallbackResult(methodName) { switch (methodName) { case "bootstrap": return { bootstrapped: false, reason: "context engine downgraded to legacy" }; case "maintain": return { changed: false, bytesFreed: 0, rewrittenEntries: 0, reason: "context engine downgraded to legacy" }; case "ingest": return { ingested: false }; case "ingestBatch": return { ingestedCount: 0 }; case "afterTurn": case "prepareSubagentSpawn": case "onSubagentEnded": return; case "assemble": case "compact": throw new Error(`No legacy fallback result for ${methodName}`); } } function contextEngineAbortSignal(methodParams) { if (!methodParams || typeof methodParams !== "object") return; const signal = methodParams.abortSignal; if (signal && typeof signal === "object" && "aborted" in signal) return signal; } function contextEngineAbortError(methodParams) { const signal = contextEngineAbortSignal(methodParams); if (!signal?.aborted) return; const reason = signal.reason; if (reason instanceof Error) return reason; const error = new Error(typeof reason === "string" && reason ? reason : "Context engine operation aborted."); error.name = "AbortError"; return error; } function isContextEngineAbortRejection(error, methodParams) { const signal = contextEngineAbortSignal(methodParams); if (!signal?.aborted) return false; if (error === signal.reason) return true; if (error instanceof Error) { const message = error.message.toLowerCase(); return error.name === "AbortError" || message.includes("abort") || message.includes("cancelled") || message.includes("canceled"); } return typeof error === "string" && /abort|cancelled|canceled/iu.test(error); } async function invokeFallbackContextEngineMethod(params) { const fallbackEngine = await params.getFallbackEngine(); const fallbackMethod = fallbackEngine[params.methodName]; if (typeof fallbackMethod === "function") return await fallbackMethod.call(fallbackEngine, params.methodParams); return contextEngineFallbackResult(params.methodName); } function wrapContextEngineWithRuntimeQuarantine(params) { let fallbackEnginePromise; let resolvedFallbackEngine; const getFallbackEngine = () => { fallbackEnginePromise ??= resolveDefaultContextEngine(params.defaultEngineId, params.factoryCtx).then((engine) => { resolvedFallbackEngine = engine; return engine; }); return fallbackEnginePromise; }; const fallbackInfo = () => { return resolvedFallbackEngine?.info ?? { id: params.defaultEngineId, name: params.defaultEngineId === "legacy" ? "Legacy Context Engine" : `${params.defaultEngineId} Context Engine` }; }; const isQuarantined = () => Boolean(getContextEngineQuarantine(params.engineId)); const proxy = new Proxy(params.engine, { get(target, property, receiver) { if (property === "info" && isQuarantined()) return fallbackInfo(); const value = Reflect.get(target, property, receiver); if (typeof value !== "function" || !GUARDED_CONTEXT_ENGINE_METHODS.has(property)) return typeof value === "function" ? value.bind(target) : value; const methodName = property; return async (methodParams) => { const aborted = contextEngineAbortError(methodParams); if (aborted) throw aborted; if (isQuarantined()) return await invokeFallbackContextEngineMethod({ getFallbackEngine, methodName, methodParams }); try { return await value.call(target, methodParams); } catch (error) { if (isContextEngineAbortRejection(error, methodParams)) throw error; recordContextEngineQuarantine({ engineId: params.engineId, owner: params.owner, operation: methodName, error, defaultEngineId: params.defaultEngineId }); if (methodName === "compact" || methodName === "prepareSubagentSpawn") throw error; try { return await invokeFallbackContextEngineMethod({ getFallbackEngine, methodName, methodParams }); } catch { throw error; } } }; } }); RUNTIME_QUARANTINE_PROXY_STATE.set(proxy, { engineId: params.engineId, getResolvedFallbackEngine: () => resolvedFallbackEngine }); return proxy; } /** * 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. */ async function resolveContextEngine(config, options) { 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 = { config, agentDir: options?.agentDir, workspaceDir: options?.workspaceDir }; if (!isDefaultEngine ? getContextEngineQuarantine(engineId) : void 0) return resolveDefaultContextEngine(defaultEngineId, factoryCtx); const entry = getContextEngineRegistryState().engines.get(engineId); if (!entry) { if (isDefaultEngine) throw new Error(`Context engine "${engineId}" is not registered. Available engines: ${listContextEngineIds().join(", ") || "(none)"}`); recordContextEngineQuarantine({ engineId, operation: "resolve", error: "not registered", defaultEngineId }); return resolveDefaultContextEngine(defaultEngineId, factoryCtx); } let engine; try { engine = await entry.factory(factoryCtx); } catch (factoryError) { if (isDefaultEngine) throw factoryError; recordContextEngineQuarantine({ engineId, owner: entry.owner, operation: "factory", error: factoryError, defaultEngineId }); return resolveDefaultContextEngine(defaultEngineId, factoryCtx); } let contractError; try { contractError = describeResolvedContextEngineContractError(engineId, engine); } catch (validationError) { if (isDefaultEngine) throw validationError; recordContextEngineQuarantine({ engineId, owner: entry.owner, operation: "contract-validation", error: validationError, defaultEngineId }); return resolveDefaultContextEngine(defaultEngineId, factoryCtx); } if (contractError) { if (isDefaultEngine) throw new Error(contractError); recordContextEngineQuarantine({ engineId, owner: entry.owner, operation: "contract-validation", error: contractError, defaultEngineId }); return resolveDefaultContextEngine(defaultEngineId, factoryCtx); } return wrapResolvedContextEngine(engine, { owner: entry.owner, engineId, defaultEngineId, factoryCtx }); } /** * 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, factoryCtx) { 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 wrapResolvedContextEngine(engine, { owner: defaultEntry.owner, engineId: defaultEngineId }); } //#endregion export { registerContextEngineForOwner as a, registerContextEngine as i, getContextEngineFactory as n, resolveContextEngine as o, listContextEngineQuarantines as r, resolveContextEngineOwnerPluginId as s, clearContextEnginesForOwner as t };