diff --git a/internal/agent/sanitize.go b/internal/agent/sanitize.go index 885471d5..a5e77841 100644 --- a/internal/agent/sanitize.go +++ b/internal/agent/sanitize.go @@ -20,6 +20,7 @@ import ( "log/slog" "regexp" "strings" + "unicode/utf8" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -377,33 +378,41 @@ func StripConfigLeak(content, agentType string) string { // --- NO_REPLY detection --- -// IsSilentReply checks if the text is a NO_REPLY token. -// Matching TS isSilentReplyText() from auto-reply/tokens.ts. +// IsSilentReply checks if the text begins with a NO_REPLY token. +// +// Divergent from TS isSilentReplyText() (exact-match only) — we match broadly: +// decorative wrappers (`NO_REPLY_`, `"NO_REPLY"`, `**NO_REPLY**`) AND trailing +// explanations (`NO_REPLY because offline`, `NO_REPLY: note`) suppress delivery. +// Only requirement: the token is not glued to another word (`NO_REPLYING` is NOT silent). +// Case-insensitive. +// +// Trade-off vs upstream #19537: upstream guards against suppressing substantive +// replies that end in NO_REPLY. We accept that risk because observed model output +// leans toward "NO_REPLY + reason" rather than "real reply ending in NO_REPLY". func IsSilentReply(text string) bool { trimmed := strings.TrimSpace(text) if trimmed == "" { return false } + // Strip decorative wrappers from both ends (quotes, markdown emphasis, punctuation). + stripped := strings.Trim(trimmed, "_ \t\n\r.,:;!?\"'`*~#>-()[]{}") const token = "NO_REPLY" - // Exact match - if trimmed == token { + if len(stripped) < len(token) { + return false + } + if !strings.EqualFold(stripped[:len(token)], token) { + return false + } + if len(stripped) == len(token) { return true } - // Starts with token followed by non-word char or end - if strings.HasPrefix(trimmed, token) { - rest := trimmed[len(token):] - if rest == "" || !isWordChar(rune(rest[0])) { - return true - } - } - // Ends with token preceded by non-word char - if strings.HasSuffix(trimmed, token) { - before := trimmed[:len(trimmed)-len(token)] - if before == "" || !isWordChar(rune(before[len(before)-1])) { - return true - } - } - return false + // Token must not be glued to another word — next rune must be non-alphanumeric. + next, _ := utf8.DecodeRuneInString(stripped[len(token):]) + return !isAlphaNum(next) +} + +func isAlphaNum(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') } func isWordChar(r rune) bool { diff --git a/internal/agent/sanitize_silent_reply_test.go b/internal/agent/sanitize_silent_reply_test.go new file mode 100644 index 00000000..05694026 --- /dev/null +++ b/internal/agent/sanitize_silent_reply_test.go @@ -0,0 +1,49 @@ +package agent + +import "testing" + +func TestIsSilentReply(t *testing.T) { + cases := []struct { + name string + in string + want bool + }{ + // Exact matches. + {"exact", "NO_REPLY", true}, + {"with whitespace", " NO_REPLY ", true}, + {"with newlines", "\nNO_REPLY\n", true}, + // Decorative variants — the bug report. + {"trailing underscore", "NO_REPLY_", true}, + {"double trailing underscore", "NO_REPLY__", true}, + {"leading underscore", "_NO_REPLY", true}, + {"both underscores", "_NO_REPLY_", true}, + {"trailing dot", "NO_REPLY.", true}, + {"trailing bang", "NO_REPLY!", true}, + {"double-quoted", `"NO_REPLY"`, true}, + {"single-quoted", "'NO_REPLY'", true}, + {"backticked", "`NO_REPLY`", true}, + {"markdown bold", "**NO_REPLY**", true}, + {"parenthesized", "(NO_REPLY)", true}, + // Case-insensitive. + {"lowercase", "no_reply", true}, + {"mixed case", "No_Reply", true}, + // Silent — token + explanation (user intent: prefix-match, divergent from upstream). + {"prefix + space + content", "NO_REPLY hello", true}, + {"prefix + colon + content", "NO_REPLY: offline", true}, + {"prefix + because", "NO_REPLY because user is away", true}, + // NOT silent — token glued to another word, or not at start. + {"embedded word", "NO_REPLYING", false}, + {"trailing after content", "Here you go. NO_REPLY", false}, + {"token mid-sentence", "Hello NO_REPLY world", false}, + {"empty", "", false}, + {"whitespace only", " ", false}, + {"unrelated text", "no reply needed", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := IsSilentReply(c.in); got != c.want { + t.Errorf("IsSilentReply(%q) = %v, want %v", c.in, got, c.want) + } + }) + } +}