UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

153 lines (152 loc) 6.25 kB
//#region src/plugin-sdk/pair-loop-guard-runtime.ts const PAIR_LOOP_GUARD_CONFIG_KEYS = [ "enabled", "maxEventsPerWindow", "windowSeconds", "cooldownSeconds" ]; const DEFAULT_PRUNE_INTERVAL_MS = 6e4; const KEY_SEPARATOR = ""; /** Default plugin-facing loop guard config before per-channel overrides. */ const DEFAULT_PAIR_LOOP_GUARD_CONFIG = { enabled: true, maxEventsPerWindow: 20, windowSeconds: 60, cooldownSeconds: 60 }; /** Default runtime loop guard settings derived from the default config. */ const DEFAULT_PAIR_LOOP_GUARD_SETTINGS = { enabled: DEFAULT_PAIR_LOOP_GUARD_CONFIG.enabled, maxEventsPerWindow: DEFAULT_PAIR_LOOP_GUARD_CONFIG.maxEventsPerWindow, windowMs: DEFAULT_PAIR_LOOP_GUARD_CONFIG.windowSeconds * 1e3, cooldownMs: DEFAULT_PAIR_LOOP_GUARD_CONFIG.cooldownSeconds * 1e3 }; /** Merges pair-loop configs from broad defaults to narrow overrides, ignoring undefined values. */ function mergePairLoopGuardConfig(...configs) { const merged = {}; let hasValue = false; for (const config of configs) { if (!config) continue; for (const key of PAIR_LOOP_GUARD_CONFIG_KEYS) if (config[key] !== void 0) { switch (key) { case "enabled": merged.enabled = config.enabled; break; case "maxEventsPerWindow": merged.maxEventsPerWindow = config.maxEventsPerWindow; break; case "windowSeconds": merged.windowSeconds = config.windowSeconds; break; case "cooldownSeconds": merged.cooldownSeconds = config.cooldownSeconds; } hasValue = true; } } return hasValue ? merged : void 0; } function positiveInteger(value) { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : void 0; } /** Resolves runtime loop guard settings from config/defaults and the channel default-enabled gate. */ function resolvePairLoopGuardSettings(params) { const configuredEnabled = typeof params.config?.enabled === "boolean" ? params.config.enabled : typeof params.defaultsConfig?.enabled === "boolean" ? params.defaultsConfig.enabled : DEFAULT_PAIR_LOOP_GUARD_CONFIG.enabled; const maxEventsPerWindow = positiveInteger(params.config?.maxEventsPerWindow) ?? positiveInteger(params.defaultsConfig?.maxEventsPerWindow) ?? DEFAULT_PAIR_LOOP_GUARD_CONFIG.maxEventsPerWindow; const windowSeconds = positiveInteger(params.config?.windowSeconds) ?? positiveInteger(params.defaultsConfig?.windowSeconds) ?? DEFAULT_PAIR_LOOP_GUARD_CONFIG.windowSeconds; const cooldownSeconds = positiveInteger(params.config?.cooldownSeconds) ?? positiveInteger(params.defaultsConfig?.cooldownSeconds) ?? DEFAULT_PAIR_LOOP_GUARD_CONFIG.cooldownSeconds; return { enabled: params.defaultEnabled && configuredEnabled, maxEventsPerWindow, windowMs: windowSeconds * 1e3, cooldownMs: cooldownSeconds * 1e3 }; } function buildPairKey(params) { const lhs = params.senderId < params.receiverId ? params.senderId : params.receiverId; const rhs = params.senderId < params.receiverId ? params.receiverId : params.senderId; return [ params.scopeId, params.conversationId, lhs, rhs ].join(KEY_SEPARATOR); } function pruneRecentEvents(entry, nowMs, windowMs) { const cutoff = nowMs - windowMs; entry.recentEvents = entry.recentEvents.filter((event) => event.timestampMs > cutoff); } function countCurrentWindowEvents(entry, nowMs) { return entry.recentEvents.filter((event) => event.timestampMs <= nowMs).length; } /** Creates an in-memory pair-loop guard with bounded periodic pruning. */ function createPairLoopGuard(params) { const tracked = /* @__PURE__ */ new Map(); const pruneIntervalMs = params?.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; let nextPruneAtMs = 0; function pruneInactiveTrackedPairs(nowMs) { if (pruneIntervalMs <= 0 || nowMs < nextPruneAtMs) return; nextPruneAtMs = nowMs + pruneIntervalMs; for (const [key, entry] of tracked) { pruneRecentEvents(entry, nowMs, entry.windowMs); if (entry.recentEvents.length === 0 && entry.cooldownUntilMs <= nowMs) tracked.delete(key); } } function recordAndCheck(paramsLocal) { if (!paramsLocal.settings.enabled) return { suppressed: false }; if (!paramsLocal.scopeId || !paramsLocal.conversationId || !paramsLocal.senderId || !paramsLocal.receiverId) return { suppressed: false }; if (paramsLocal.senderId === paramsLocal.receiverId) return { suppressed: false }; const maxEventsPerWindow = Math.floor(paramsLocal.settings.maxEventsPerWindow); const windowMs = Math.floor(paramsLocal.settings.windowMs); const cooldownMs = Math.floor(paramsLocal.settings.cooldownMs); if (maxEventsPerWindow <= 0 || windowMs <= 0 || cooldownMs <= 0) return { suppressed: false }; const nowMs = paramsLocal.nowMs ?? Date.now(); pruneInactiveTrackedPairs(nowMs); const key = buildPairKey(paramsLocal); let entry = tracked.get(key); if (!entry) { entry = { recentEvents: [], windowMs, cooldownStartedAtMs: 0, cooldownUntilMs: 0 }; tracked.set(key, entry); } entry.windowMs = windowMs; pruneRecentEvents(entry, nowMs, windowMs); const eventId = paramsLocal.eventId?.trim(); if (eventId && entry.recentEvents.some((event) => event.eventId === eventId)) return { suppressed: false }; if (entry.cooldownStartedAtMs <= nowMs && entry.cooldownUntilMs > nowMs) return { suppressed: true, cooldownUntilMs: entry.cooldownUntilMs }; entry.recentEvents.push({ timestampMs: nowMs, ...eventId ? { eventId } : {} }); if (countCurrentWindowEvents(entry, nowMs) > maxEventsPerWindow) { entry.cooldownStartedAtMs = nowMs; entry.cooldownUntilMs = nowMs + cooldownMs; entry.recentEvents = entry.recentEvents.filter((event) => event.timestampMs > nowMs); return { suppressed: true, cooldownUntilMs: entry.cooldownUntilMs }; } return { suppressed: false }; } return { recordAndCheck, clear: () => { tracked.clear(); nextPruneAtMs = 0; }, snapshot: () => Array.from(tracked.entries()).map(([key, entry]) => ({ key, recentCount: entry.recentEvents.length, cooldownUntilMs: entry.cooldownUntilMs })) }; } //#endregion export { resolvePairLoopGuardSettings as a, mergePairLoopGuardConfig as i, DEFAULT_PAIR_LOOP_GUARD_SETTINGS as n, createPairLoopGuard as r, DEFAULT_PAIR_LOOP_GUARD_CONFIG as t };