mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 14:17:40 +00:00
fix(agent): strip internal routing tags from outbound delivery (#416)
- Introduced StripMessageDirectives in the agent loop to remove internal [[name:value]] routing tags from user-facing content while preserving them in session store for model context - Narrowed regex from wildcard `(?s)\[\[.*?\]\]` to structured `\[\[\w+(?::[^\]\n]+)?\]\]` to avoid false positives on legitimate content - Preserved [[tts...]] tags for TTS AutoTagged pipeline compatibility - Added 15 unit tests covering strip, preserve, and edge cases - Fixed step numbering in agent loop (steps 5-9)
This commit is contained in:
1 parent
0881d7fc4e
commit
7fa2d201fb
3 files changed
+70
-5
No files matched your search
@@ -1348,14 +1348,14 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
|
||||
}
|
||||
|
||||
// 4. Full sanitization pipeline (matching TS extractAssistantText + sanitizeUserFacingText)
|
||||
// 5. Full sanitization pipeline (matching TS extractAssistantText + sanitizeUserFacingText)
|
||||
finalContent = SanitizeAssistantContent(finalContent)
|
||||
|
||||
// 4b. Config leak detection — disabled: too many false positives
|
||||
// (e.g. agent explaining public architecture mentioning SOUL.md etc.)
|
||||
// finalContent = StripConfigLeak(finalContent, l.agentType)
|
||||
|
||||
// 5. Handle NO_REPLY: save to session for context but mark as silent.
|
||||
// 6. Handle NO_REPLY: save to session for context but mark as silent.
|
||||
// Matching TS: NO_REPLY is saved (via resolveSilentReplyFallbackText) but
|
||||
// filtered at the payload level before delivery.
|
||||
isSilent := IsSilentReply(finalContent)
|
||||
@@ -1373,7 +1373,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
finalContent += "\n\n---\n_" + i18n.T(locale, i18n.MsgSkillNudgePostscript) + "_"
|
||||
}
|
||||
|
||||
// 6. Fallback for empty content
|
||||
// 7. Fallback for empty content
|
||||
if finalContent == "" {
|
||||
if len(asyncToolCalls) > 0 {
|
||||
finalContent = "..."
|
||||
@@ -1493,14 +1493,16 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
}
|
||||
|
||||
// If silent, return empty content so gateway suppresses delivery.
|
||||
// 8. Metadata Stripping: Clean internal [[...]] tags for user-facing content
|
||||
// (Session version is already saved in assistantMsg above)
|
||||
finalContent = StripMessageDirectives(finalContent)
|
||||
if isSilent {
|
||||
slog.Info("agent loop: NO_REPLY detected, suppressing delivery",
|
||||
"agent", l.id, "session", req.SessionKey)
|
||||
finalContent = ""
|
||||
}
|
||||
|
||||
// 5. Maybe summarize
|
||||
// 9. Maybe summarize
|
||||
l.maybeSummarize(ctx, req.SessionKey)
|
||||
|
||||
return &RunResult{
|
||||
|
||||
@@ -407,3 +407,25 @@ func IsSilentReply(text string) bool {
|
||||
func isWordChar(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_'
|
||||
}
|
||||
|
||||
// --- Message Directives ([[name:value]]) ---
|
||||
|
||||
// messageDirectivePattern matches structured routing tags: [[word]] or [[word:value]].
|
||||
// Single-line only (no (?s) dotall). Does NOT match arbitrary [[...]] content.
|
||||
var messageDirectivePattern = regexp.MustCompile(`\[\[\w+(?::[^\]\n]+)?\]\]`)
|
||||
|
||||
// StripMessageDirectives removes internal [[...]] routing tags from user-facing text,
|
||||
// preserving [[tts...]] tags needed by the TTS auto-apply pipeline.
|
||||
func StripMessageDirectives(content string) string {
|
||||
if !strings.Contains(content, "[[") {
|
||||
return content
|
||||
}
|
||||
result := messageDirectivePattern.ReplaceAllStringFunc(content, func(match string) string {
|
||||
inner := match[2 : len(match)-2] // strip [[ and ]]
|
||||
if strings.HasPrefix(inner, "tts") {
|
||||
return match // preserve for TTS AutoTagged mode
|
||||
}
|
||||
return ""
|
||||
})
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStripMessageDirectives(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"no tags", "Hello world", "Hello world"},
|
||||
{"single tag prefix", "[[reply_to:123]] Hello", "Hello"},
|
||||
{"single keyword tag", "[[voice]] Hello", "Hello"},
|
||||
{"multiple tags", "[[reply_to:1]] Hello [[silent]]", "Hello"},
|
||||
{"tag only", "[[reply_to:abc]]", ""},
|
||||
{"tag mid-sentence", "Say [[voice]] something", "Say something"},
|
||||
|
||||
// TTS tags must be preserved for TTS AutoTagged pipeline
|
||||
{"preserve tts bare", "[[tts]] Hello", "[[tts]] Hello"},
|
||||
{"preserve tts with param", "[[tts:en]] Hello", "[[tts:en]] Hello"},
|
||||
{"preserve tts:text block", "[[tts:text]] Hello [[/tts:text]]", "[[tts:text]] Hello [[/tts:text]]"},
|
||||
{"strip non-tts but keep tts", "[[reply_to:1]] [[tts]] Hello", "[[tts]] Hello"},
|
||||
|
||||
// Should NOT match non-directive patterns
|
||||
{"no match without word chars", "text [[ ]] more", "text [[ ]] more"},
|
||||
{"no match multiline", "text [[\nfoo\n]] more", "text [[\nfoo\n]] more"},
|
||||
|
||||
// Early exit: no [[ at all
|
||||
{"fast path no brackets", "just plain text", "just plain text"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := StripMessageDirectives(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("StripMessageDirectives(%q)\n got %q\n want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user