mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
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:
1 parent
59a8c25349
commit
d4a08c818f
2 files changed
+77
-19
No files matched your search
+28
-19
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user