fix(agent): broaden NO_REPLY detection to cover decorative variants

Models occasionally emit NO_REPLY with trailing underscores, quotes,
markdown emphasis, or short explanations (e.g. `NO_REPLY_`,
`**NO_REPLY**`, `NO_REPLY: offline`). Previous word-boundary check
treated `_` as a word char so `NO_REPLY_` slipped through and was
delivered to end users.

Rewrite IsSilentReply to strip decorative wrappers, then prefix-match
case-insensitively. Token is only rejected when glued to another
word character (e.g. `NO_REPLYING`). Intentional divergence from
upstream #19537 — documented in the doc comment.
This commit is contained in:
viettranx committed 2026-04-18 21:46:26 +07:00
1 parent 59a8c25349
commit d4a08c818f
2 files changed
+77 -19

No files matched your search

+28 -19
View File
@@ -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 {
@@ -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)
}
})
}
}