Files
openclaw/src/imessage/monitor/reflection-guard.ts
OfflynAI adb9234d03 fix(imessage): prevent echo loop from leaking internal metadata and amplifying NO_REPLY into queue overflow (#33295)
* fix(imessage): prevent echo loop from leaking internal metadata and amplifying NO_REPLY into queue overflow

- Add outbound sanitization at channel boundary (sanitize-outbound.ts):
  strips thinking/reasoning tags, relevant-memories tags, model-specific
  separators (+#+#), and assistant role markers before iMessage delivery

- Add inbound reflection guard (reflection-guard.ts): detects and drops
  messages containing assistant-internal markers that indicate a reflected
  outbound message, preventing recursive echo amplification

- Harden echo cache: increase text TTL from 5s to 30s to catch delayed
  reflections that previously expired before the echo could be detected

- Add loop rate limiter (loop-rate-limiter.ts): per-conversation rapid-fire
  detection that suppresses conversations exceeding threshold within a
  time window, acting as a safety net against amplification

Closes #33281

* fix(imessage): address review — stricter reflection regex, loop-aware rate limiter

- Reflection guard: require closing > bracket on thinking/final/memory
  tag patterns to prevent false-positives on user phrases like
  '<final answer>' or '<thought experiment>' (#33295 review)

- Rate limiter: only record echo/reflection/from-me drops instead of
  all dispatches, so the limiter acts as a loop-specific escalation
  mechanism rather than a general throttle on normal conversation
  velocity (#33295 review)

* Changelog: add iMessage echo-loop hardening entry

* iMessage: restore short echo-text TTL

* iMessage: ignore reflection markers in code

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-06 19:19:57 -05:00

65 lines
2.2 KiB
TypeScript

/**
* Detects inbound messages that are reflections of assistant-originated content.
* These patterns indicate internal metadata leaked into a channel and then
* bounced back as a new inbound message — creating an echo loop.
*/
import { findCodeRegions, isInsideCode } from "../../shared/text/code-regions.js";
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/;
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/i;
// Require closing `>` to avoid false-positives on phrases like "<thought experiment>".
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/i;
const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i;
// Require closing `>` to avoid false-positives on phrases like "<final answer>".
const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i;
const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [
{ re: INTERNAL_SEPARATOR_RE, label: "internal-separator" },
{ re: ASSISTANT_ROLE_MARKER_RE, label: "assistant-role-marker" },
{ re: THINKING_TAG_RE, label: "thinking-tag" },
{ re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" },
{ re: FINAL_TAG_RE, label: "final-tag" },
];
export type ReflectionDetection = {
isReflection: boolean;
matchedLabels: string[];
};
function hasMatchOutsideCode(text: string, re: RegExp): boolean {
const codeRegions = findCodeRegions(text);
const globalRe = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
for (const match of text.matchAll(globalRe)) {
const start = match.index ?? -1;
if (start >= 0 && !isInsideCode(start, codeRegions)) {
return true;
}
}
return false;
}
/**
* Check whether an inbound message appears to be a reflection of
* assistant-originated content. Returns matched pattern labels for telemetry.
*/
export function detectReflectedContent(text: string): ReflectionDetection {
if (!text) {
return { isReflection: false, matchedLabels: [] };
}
const matchedLabels: string[] = [];
for (const { re, label } of REFLECTION_PATTERNS) {
if (hasMatchOutsideCode(text, re)) {
matchedLabels.push(label);
}
}
return {
isReflection: matchedLabels.length > 0,
matchedLabels,
};
}