diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 8fd7328d1..b185a1ef2 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -930,6 +930,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/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a11cf53b8..921e8c4d8 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{ diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 15ef4ff73..c0d76f888 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 { @@ -50,7 +53,15 @@ 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 = "" } // HasSentInRound returns true if the message tool sent a message during the current round. @@ -86,6 +97,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 +115,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..bc53a9692 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -194,6 +194,68 @@ 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_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" {