From 7fa2d201fbd840ad8b37842851ba1b8ce36b965c Mon Sep 17 00:00:00 2001 From: badgerbees <93577481+badgerbees@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:24:10 +0700 Subject: [PATCH] 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) --- internal/agent/loop.go | 12 ++++--- internal/agent/sanitize.go | 22 ++++++++++++ internal/agent/sanitize_directives_test.go | 41 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 internal/agent/sanitize_directives_test.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 435a23f0..3e0804e0 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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{ diff --git a/internal/agent/sanitize.go b/internal/agent/sanitize.go index b0917e02..decd0d70 100644 --- a/internal/agent/sanitize.go +++ b/internal/agent/sanitize.go @@ -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) +} diff --git a/internal/agent/sanitize_directives_test.go b/internal/agent/sanitize_directives_test.go new file mode 100644 index 00000000..4fe34e66 --- /dev/null +++ b/internal/agent/sanitize_directives_test.go @@ -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) + } + }) + } +}