Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
15 changes: 15 additions & 0 deletions pkg/channels/telegram/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
25 changes: 24 additions & 1 deletion pkg/tools/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down
62 changes: 62 additions & 0 deletions pkg/tools/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down