mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 08:00:21 +00:00
* fix(auto-reply): strip leading NO_REPLY tokens to prevent silent-reply leak * fix(auto-reply): preserve substantive NO_REPLY leading text * fix(agents): preserve ACP silent-prefix cumulative deltas * fix(auto-reply): harden silent-token streaming paths * fix(auto-reply): normalize glued silent tokens consistently --------- Co-authored-by: termtek <termtek@ubuntu.tail2b72cd.ts.net>
180 lines
5.5 KiB
TypeScript
180 lines
5.5 KiB
TypeScript
import { escapeRegExp } from "../utils.js";
|
|
|
|
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
|
export const SILENT_REPLY_TOKEN = "NO_REPLY";
|
|
|
|
const silentExactRegexByToken = new Map<string, RegExp>();
|
|
const silentTrailingRegexByToken = new Map<string, RegExp>();
|
|
const silentLeadingAttachedRegexByToken = new Map<string, RegExp>();
|
|
|
|
function getSilentExactRegex(token: string): RegExp {
|
|
const cached = silentExactRegexByToken.get(token);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const escaped = escapeRegExp(token);
|
|
const regex = new RegExp(`^\\s*${escaped}\\s*$`, "i");
|
|
silentExactRegexByToken.set(token, regex);
|
|
return regex;
|
|
}
|
|
|
|
function getSilentTrailingRegex(token: string): RegExp {
|
|
const cached = silentTrailingRegexByToken.get(token);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const escaped = escapeRegExp(token);
|
|
const regex = new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`);
|
|
silentTrailingRegexByToken.set(token, regex);
|
|
return regex;
|
|
}
|
|
|
|
export function isSilentReplyText(
|
|
text: string | undefined,
|
|
token: string = SILENT_REPLY_TOKEN,
|
|
): boolean {
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
// Match only the exact silent token with optional surrounding whitespace.
|
|
// This prevents substantive replies ending with NO_REPLY from being suppressed (#19537).
|
|
return getSilentExactRegex(token).test(text);
|
|
}
|
|
|
|
type SilentReplyActionEnvelope = { action?: unknown };
|
|
|
|
export function isSilentReplyEnvelopeText(
|
|
text: string | undefined,
|
|
token: string = SILENT_REPLY_TOKEN,
|
|
): boolean {
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
const trimmed = text.trim();
|
|
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}") || !trimmed.includes(token)) {
|
|
return false;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as SilentReplyActionEnvelope;
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return false;
|
|
}
|
|
const keys = Object.keys(parsed);
|
|
return (
|
|
keys.length === 1 &&
|
|
keys[0] === "action" &&
|
|
typeof parsed.action === "string" &&
|
|
parsed.action.trim() === token
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function isSilentReplyPayloadText(
|
|
text: string | undefined,
|
|
token: string = SILENT_REPLY_TOKEN,
|
|
): boolean {
|
|
return isSilentReplyText(text, token) || isSilentReplyEnvelopeText(text, token);
|
|
}
|
|
|
|
/**
|
|
* Strip a trailing silent reply token from mixed-content text.
|
|
* Returns the remaining text with the token removed (trimmed).
|
|
* If the result is empty, the entire message should be treated as silent.
|
|
*/
|
|
export function stripSilentToken(text: string, token: string = SILENT_REPLY_TOKEN): string {
|
|
return text.replace(getSilentTrailingRegex(token), "").trim();
|
|
}
|
|
|
|
const silentLeadingRegexByToken = new Map<string, RegExp>();
|
|
|
|
function getSilentLeadingAttachedRegex(token: string): RegExp {
|
|
const cached = silentLeadingAttachedRegexByToken.get(token);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const escaped = escapeRegExp(token);
|
|
// Match one or more leading occurrences of the token where the final token
|
|
// is glued directly to visible word-start content (for example
|
|
// `NO_REPLYhello`), without treating punctuation-start text like
|
|
// `NO_REPLY: explanation` as a silent prefix.
|
|
const regex = new RegExp(`^\\s*(?:${escaped}\\s+)*${escaped}(?=[\\p{L}\\p{N}])`, "iu");
|
|
silentLeadingAttachedRegexByToken.set(token, regex);
|
|
return regex;
|
|
}
|
|
|
|
function getSilentLeadingRegex(token: string): RegExp {
|
|
const cached = silentLeadingRegexByToken.get(token);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const escaped = escapeRegExp(token);
|
|
// Match one or more leading occurrences of the token, each optionally followed by whitespace
|
|
const regex = new RegExp(`^(?:\\s*${escaped})+\\s*`, "i");
|
|
silentLeadingRegexByToken.set(token, regex);
|
|
return regex;
|
|
}
|
|
|
|
/**
|
|
* Strip leading silent reply tokens from text.
|
|
* Handles cases like "NO_REPLYThe user is saying..." where the token
|
|
* is not separated from the following text.
|
|
*/
|
|
export function stripLeadingSilentToken(text: string, token: string = SILENT_REPLY_TOKEN): string {
|
|
return text.replace(getSilentLeadingRegex(token), "").trim();
|
|
}
|
|
|
|
/**
|
|
* Check whether text starts with one or more leading silent reply tokens where
|
|
* the final token is glued directly to visible content.
|
|
*/
|
|
export function startsWithSilentToken(
|
|
text: string | undefined,
|
|
token: string = SILENT_REPLY_TOKEN,
|
|
): boolean {
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
return getSilentLeadingAttachedRegex(token).test(text);
|
|
}
|
|
|
|
export function isSilentReplyPrefixText(
|
|
text: string | undefined,
|
|
token: string = SILENT_REPLY_TOKEN,
|
|
): boolean {
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
const trimmed = text.trimStart();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
// Guard against suppressing natural-language "No..." text while still
|
|
// catching uppercase lead fragments like "NO" from streamed NO_REPLY.
|
|
if (trimmed !== trimmed.toUpperCase()) {
|
|
return false;
|
|
}
|
|
const normalized = trimmed.toUpperCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
if (normalized.length < 2) {
|
|
return false;
|
|
}
|
|
if (/[^A-Z_]/.test(normalized)) {
|
|
return false;
|
|
}
|
|
const tokenUpper = token.toUpperCase();
|
|
if (!tokenUpper.startsWith(normalized)) {
|
|
return false;
|
|
}
|
|
if (normalized.includes("_")) {
|
|
return true;
|
|
}
|
|
// Keep underscore guard for generic tokens to avoid suppressing unrelated
|
|
// uppercase words (e.g. HEART/HE with HEARTBEAT_OK). Only allow bare "NO"
|
|
// because NO_REPLY streaming can transiently emit that fragment.
|
|
return tokenUpper === SILENT_REPLY_TOKEN && normalized === "NO";
|
|
}
|