/** Resolved pair-loop guard settings in milliseconds for runtime checks. */ export type PairLoopGuardSettings = { /** Whether protection is active after config and channel capability gates. */ enabled: boolean; /** Number of pair events allowed before cooldown starts. */ maxEventsPerWindow: number; /** Rolling event window size in milliseconds. */ windowMs: number; /** Suppression duration in milliseconds once the threshold is exceeded. */ cooldownMs: number; }; /** User-facing pair-loop guard config accepted by channel plugins. */ export type PairLoopGuardConfig = { /** Enables or disables loop protection for the channel/account scope. */ enabled?: boolean; /** Number of pair events allowed before cooldown starts. */ maxEventsPerWindow?: number; /** Rolling event window size in seconds for config files. */ windowSeconds?: number; /** Suppression duration in seconds for config files. */ cooldownSeconds?: number; }; const PAIR_LOOP_GUARD_CONFIG_KEYS = [ "enabled", "maxEventsPerWindow", "windowSeconds", "cooldownSeconds", ] as const satisfies ReadonlyArray; /** Result of recording one pair interaction against the loop guard. */ export type PairLoopGuardResult = | { suppressed: false } | { suppressed: true; cooldownUntilMs: number }; /** Snapshot entry for observability and tests. */ export type PairLoopGuardSnapshotEntry = { /** Internal pair key containing scope, conversation, and unordered participant ids. */ key: string; /** Number of retained events in the current window. */ recentCount: number; /** Epoch milliseconds when cooldown ends, or zero when inactive. */ cooldownUntilMs: number; }; type PairLoopGuardEntry = { recentMs: number[]; windowMs: number; cooldownStartedAtMs: number; cooldownUntilMs: number; }; /** In-memory guard for suppressing repeated bidirectional bot pair loops. */ export type PairLoopGuard = { /** Records one sender/receiver interaction and reports whether it enters or is inside cooldown. */ recordAndCheck: (params: { /** Channel/account/provider scope that owns this conversation. */ scopeId: string; /** Conversation/thread identifier where the bidirectional exchange happened. */ conversationId: string; /** Sender id for this event; paired with receiverId without direction. */ senderId: string; /** Receiver id for this event; paired with senderId without direction. */ receiverId: string; /** Resolved guard thresholds for the current channel/account. */ settings: PairLoopGuardSettings; /** Optional test/runtime clock override in epoch milliseconds. */ nowMs?: number; }) => PairLoopGuardResult; /** Clears all tracked pair state and scheduled pruning state. */ clear: () => void; /** Returns tracked pair counters for diagnostics and tests without exposing mutable state. */ snapshot: () => PairLoopGuardSnapshotEntry[]; }; const DEFAULT_PRUNE_INTERVAL_MS = 60_000; const KEY_SEPARATOR = "\u0001"; /** Default plugin-facing loop guard config before per-channel overrides. */ export const DEFAULT_PAIR_LOOP_GUARD_CONFIG: Required = { enabled: true, maxEventsPerWindow: 20, windowSeconds: 60, cooldownSeconds: 60, }; /** Default runtime loop guard settings derived from the default config. */ export const DEFAULT_PAIR_LOOP_GUARD_SETTINGS: PairLoopGuardSettings = { enabled: DEFAULT_PAIR_LOOP_GUARD_CONFIG.enabled, maxEventsPerWindow: DEFAULT_PAIR_LOOP_GUARD_CONFIG.maxEventsPerWindow, windowMs: DEFAULT_PAIR_LOOP_GUARD_CONFIG.windowSeconds * 1000, cooldownMs: DEFAULT_PAIR_LOOP_GUARD_CONFIG.cooldownSeconds * 1000, }; /** Merges pair-loop configs from broad defaults to narrow overrides, ignoring undefined values. */ export function mergePairLoopGuardConfig( ...configs: Array ): PairLoopGuardConfig | undefined { const merged: PairLoopGuardConfig = {}; let hasValue = false; for (const config of configs) { if (!config) { continue; } for (const key of PAIR_LOOP_GUARD_CONFIG_KEYS) { if (config[key] !== undefined) { 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; break; } hasValue = true; } } } return hasValue ? merged : undefined; } function positiveInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined; } /** Resolves runtime loop guard settings from config/defaults and the channel default-enabled gate. */ export function resolvePairLoopGuardSettings(params: { config?: PairLoopGuardConfig; defaultsConfig?: PairLoopGuardConfig; defaultEnabled: boolean; }): PairLoopGuardSettings { 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 { // Channel-level capability gates can disable protection even when config/defaults enable it. enabled: params.defaultEnabled && configuredEnabled, maxEventsPerWindow, windowMs: windowSeconds * 1000, cooldownMs: cooldownSeconds * 1000, }; } function buildPairKey(params: { scopeId: string; conversationId: string; senderId: string; receiverId: string; }): string { // Sort sender/receiver so A->B and B->A count as the same bot loop pair. 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 pruneRecentTimestamps(entry: PairLoopGuardEntry, nowMs: number, windowMs: number): void { const cutoff = nowMs - windowMs; entry.recentMs = entry.recentMs.filter((timestampMs) => timestampMs > cutoff); } function countCurrentWindowEvents(entry: PairLoopGuardEntry, nowMs: number): number { return entry.recentMs.filter((timestampMs) => timestampMs <= nowMs).length; } /** Creates an in-memory pair-loop guard with bounded periodic pruning. */ export function createPairLoopGuard(params?: { pruneIntervalMs?: number }): PairLoopGuard { const tracked = new Map(); const pruneIntervalMs = params?.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; let nextPruneAtMs = 0; function pruneInactiveTrackedPairs(nowMs: number): void { if (pruneIntervalMs <= 0 || nowMs < nextPruneAtMs) { return; } nextPruneAtMs = nowMs + pruneIntervalMs; for (const [key, entry] of tracked) { pruneRecentTimestamps(entry, nowMs, entry.windowMs); if (entry.recentMs.length === 0 && entry.cooldownUntilMs <= nowMs) { tracked.delete(key); } } } function recordAndCheck(paramsLocal: { scopeId: string; conversationId: string; senderId: string; receiverId: string; settings: PairLoopGuardSettings; nowMs?: number; }): PairLoopGuardResult { 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 = { recentMs: [], windowMs, cooldownStartedAtMs: 0, cooldownUntilMs: 0 }; tracked.set(key, entry); } if (entry.cooldownStartedAtMs <= nowMs && entry.cooldownUntilMs > nowMs) { return { suppressed: true, cooldownUntilMs: entry.cooldownUntilMs }; } entry.windowMs = windowMs; pruneRecentTimestamps(entry, nowMs, windowMs); entry.recentMs.push(nowMs); if (countCurrentWindowEvents(entry, nowMs) > maxEventsPerWindow) { entry.cooldownStartedAtMs = nowMs; entry.cooldownUntilMs = nowMs + cooldownMs; // Keep only future records during cooldown; past events should not extend suppression. entry.recentMs = entry.recentMs.filter((timestampMs) => 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.recentMs.length, cooldownUntilMs: entry.cooldownUntilMs, })), }; }