From c4080436dbc33a9e2c5f338f18c268faa4a59402 Mon Sep 17 00:00:00 2001 From: mosir Date: Sat, 28 Feb 2026 11:13:16 +0800 Subject: [PATCH 1/4] fix(telegram): ignore bot-originated inbound messages to prevent echo loop --- pkg/channels/telegram/telegram.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a11cf53b8..f3aaa1307 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -355,6 +355,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if user == nil { return fmt.Errorf("message sender (user) is nil") } + if user.IsBot { + logger.DebugCF("telegram", "Ignoring bot-originated message", map[string]any{ + "user_id": user.ID, + }) + return nil + } + // Defensive fallback: ignore messages from this bot by username to avoid self-loop echoes. + if botUsername := c.bot.Username(); botUsername != "" && strings.EqualFold(user.Username, botUsername) { + logger.DebugCF("telegram", "Ignoring self message by username", map[string]any{ + "user_id": user.ID, + "username": user.Username, + "bot_name": botUsername, + }) + return nil + } platformID := fmt.Sprintf("%d", user.ID) sender := bus.SenderInfo{ From a49803ec9077b56c6c7666ff4461361f0a4b7e32 Mon Sep 17 00:00:00 2001 From: mosir Date: Sat, 28 Feb 2026 11:26:22 +0800 Subject: [PATCH 2/4] fix(message): suppress duplicate sends within same round --- pkg/tools/message.go | 18 ++++++++++++++++++ pkg/tools/message_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 15ef4ff73..50335a88d 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -12,6 +12,9 @@ type MessageTool struct { defaultChannel string defaultChatID string sentInRound bool // Tracks whether a message was sent in the current processing round + lastChannel string + lastChatID string + lastContent string } func NewMessageTool() *MessageTool { @@ -51,6 +54,9 @@ func (t *MessageTool) SetContext(channel, chatID string) { t.defaultChannel = channel t.defaultChatID = chatID t.sentInRound = false // Reset send tracking for new processing round + t.lastChannel = "" + t.lastChatID = "" + t.lastContent = "" } // HasSentInRound returns true if the message tool sent a message during the current round. @@ -86,6 +92,15 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes return &ToolResult{ForLLM: "Message sending not configured", IsError: true} } + // Guard against accidental repeated tool-calls in one LLM round. + // If the same target and content were already sent, suppress duplicate delivery. + if t.sentInRound && t.lastChannel == channel && t.lastChatID == chatID && t.lastContent == content { + return &ToolResult{ + ForLLM: "Duplicate message suppressed in current round", + Silent: true, + } + } + if err := t.sendCallback(channel, chatID, content); err != nil { return &ToolResult{ ForLLM: fmt.Sprintf("sending message: %v", err), @@ -95,6 +110,9 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes } t.sentInRound = true + t.lastChannel = channel + t.lastChatID = chatID + t.lastContent = content // Silent: user already received the message directly return &ToolResult{ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index 717c1117b..6cc643860 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -194,6 +194,38 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) { } } +func TestMessageTool_Execute_SuppressDuplicateInSameRound(t *testing.T) { + tool := NewMessageTool() + tool.SetContext("test-channel", "test-chat-id") + + callCount := 0 + tool.SetSendCallback(func(channel, chatID, content string) error { + callCount++ + return nil + }) + + ctx := context.Background() + args := map[string]any{ + "content": "same message", + } + + first := tool.Execute(ctx, args) + second := tool.Execute(ctx, args) + + if callCount != 1 { + t.Fatalf("send callback call count = %d, want 1", callCount) + } + if !first.Silent || first.IsError { + t.Fatalf("first result unexpected: %+v", first) + } + if !second.Silent || second.IsError { + t.Fatalf("second result unexpected: %+v", second) + } + if second.ForLLM != "Duplicate message suppressed in current round" { + t.Fatalf("second ForLLM = %q", second.ForLLM) + } +} + func TestMessageTool_Name(t *testing.T) { tool := NewMessageTool() if tool.Name() != "message" { From f34ca8369be9c2cd5363fa61b951b843814b5fcf Mon Sep 17 00:00:00 2001 From: mosir Date: Sat, 28 Feb 2026 12:47:38 +0800 Subject: [PATCH 3/4] fix(message): preserve same-round dedup by resetting only once per round --- pkg/agent/loop.go | 3 +++ pkg/tools/message.go | 7 ++++++- pkg/tools/message_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 29827d0b2..14adc11f0 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -879,6 +879,9 @@ func (al *AgentLoop) runLLMIteration( func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) { // Use ContextualTool interface instead of type assertions if tool, ok := agent.Tools.Get("message"); ok { + if rt, ok := tool.(interface{ BeginRound() }); ok { + rt.BeginRound() + } if mt, ok := tool.(tools.ContextualTool); ok { mt.SetContext(channel, chatID) } diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 50335a88d..c0d76f888 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -53,7 +53,12 @@ func (t *MessageTool) Parameters() map[string]any { func (t *MessageTool) SetContext(channel, chatID string) { t.defaultChannel = channel t.defaultChatID = chatID - t.sentInRound = false // Reset send tracking for new processing round +} + +// BeginRound resets per-round send tracking. +// AgentLoop calls this once per inbound message before tool iterations begin. +func (t *MessageTool) BeginRound() { + t.sentInRound = false t.lastChannel = "" t.lastChatID = "" t.lastContent = "" diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index 6cc643860..bc53a9692 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -226,6 +226,36 @@ func TestMessageTool_Execute_SuppressDuplicateInSameRound(t *testing.T) { } } +func TestMessageTool_Execute_AllowsSameContentAfterBeginRound(t *testing.T) { + tool := NewMessageTool() + tool.SetContext("test-channel", "test-chat-id") + + callCount := 0 + tool.SetSendCallback(func(channel, chatID, content string) error { + callCount++ + return nil + }) + + ctx := context.Background() + args := map[string]any{"content": "same message"} + + first := tool.Execute(ctx, args) + if first.IsError || !first.Silent { + t.Fatalf("first result unexpected: %+v", first) + } + + tool.BeginRound() + tool.SetContext("test-channel", "test-chat-id") + + second := tool.Execute(ctx, args) + if second.IsError || !second.Silent { + t.Fatalf("second result unexpected: %+v", second) + } + if callCount != 2 { + t.Fatalf("send callback call count = %d, want 2", callCount) + } +} + func TestMessageTool_Name(t *testing.T) { tool := NewMessageTool() if tool.Name() != "message" { From 7b6b9c46768f38f7abd5ef86920ab2d5f395685a Mon Sep 17 00:00:00 2001 From: mosir Date: Sat, 28 Feb 2026 13:05:29 +0800 Subject: [PATCH 4/4] chore(lint): format telegram channel file --- pkg/channels/telegram/telegram.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f3aaa1307..921e8c4d8 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -364,9 +364,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes // Defensive fallback: ignore messages from this bot by username to avoid self-loop echoes. if botUsername := c.bot.Username(); botUsername != "" && strings.EqualFold(user.Username, botUsername) { logger.DebugCF("telegram", "Ignoring self message by username", map[string]any{ - "user_id": user.ID, - "username": user.Username, - "bot_name": botUsername, + "user_id": user.ID, + "username": user.Username, + "bot_name": botUsername, }) return nil }