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:
badgerbees authored and GitHub committed 2026-03-24 10:24:10 +07:00
1 parent 0881d7fc4e
commit 7fa2d201fb
3 files changed
+70 -5

No files matched your search

+7 -5
View File
@@ -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{
+22
View File
@@ -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)
}
})
}
}