openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
153 lines (152 loc) • 6.25 kB
JavaScript
//#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 };