ScuttleBot
feat: agent loop detection — warden catches repetitive and ping-pong patterns Two new circuit breakers in warden to prevent agents from getting stuck in conversation loops: 1. Repetitive loop: same message from same nick 3+ times in last 10 messages → immediate mute with "repetitive message loop detected" 2. Ping-pong loop: two nicks alternating back and forth 8+ times (A→B→A→B→A→B→A→B pattern) → mute the latest speaker with "agent ping-pong loop detected" Both use extended ban m:nick!*@* and auto-expire after cooldown. Prevents agents from burning tokens talking to each other in circles while shepherd is coordinating real work.
Commit
2673367044f4831f9f3bcfc39ebd58ccd6884b8ae68e9ce7976e1d4ddfb480ea
Parent
039edb2bd222601…
1 file changed
+88
-3
+88
-3
| --- internal/bots/warden/warden.go | ||
| +++ internal/bots/warden/warden.go | ||
| @@ -63,17 +63,26 @@ | ||
| 63 | 63 | type nickState struct { |
| 64 | 64 | tokens float64 |
| 65 | 65 | lastRefill time.Time |
| 66 | 66 | violations int |
| 67 | 67 | lastAction time.Time |
| 68 | + // Loop detection: track recent messages for repetition. | |
| 69 | + recentMsgs []string | |
| 70 | +} | |
| 71 | + | |
| 72 | +// channelMsg is a recent message for ping-pong detection. | |
| 73 | +type channelMsg struct { | |
| 74 | + nick string | |
| 75 | + text string | |
| 68 | 76 | } |
| 69 | 77 | |
| 70 | 78 | // channelState holds per-channel warden state. |
| 71 | 79 | type channelState struct { |
| 72 | - mu sync.Mutex | |
| 73 | - cfg ChannelConfig | |
| 74 | - nicks map[string]*nickState | |
| 80 | + mu sync.Mutex | |
| 81 | + cfg ChannelConfig | |
| 82 | + nicks map[string]*nickState | |
| 83 | + recentMsgs []channelMsg // channel-wide message history for ping-pong detection | |
| 75 | 84 | } |
| 76 | 85 | |
| 77 | 86 | func newChannelState(cfg ChannelConfig) *channelState { |
| 78 | 87 | cfg.defaults() |
| 79 | 88 | return &channelState{cfg: cfg, nicks: make(map[string]*nickState)} |
| @@ -104,10 +113,74 @@ | ||
| 104 | 113 | ns.tokens-- |
| 105 | 114 | return true |
| 106 | 115 | } |
| 107 | 116 | return false |
| 108 | 117 | } |
| 118 | + | |
| 119 | +// recordMessage tracks a message for loop detection. Returns true if a loop | |
| 120 | +// is detected (same message repeated 3+ times in recent history). | |
| 121 | +func (cs *channelState) recordMessage(nick, text string) bool { | |
| 122 | + cs.mu.Lock() | |
| 123 | + defer cs.mu.Unlock() | |
| 124 | + | |
| 125 | + ns, ok := cs.nicks[nick] | |
| 126 | + if !ok { | |
| 127 | + ns = &nickState{tokens: float64(cs.cfg.Burst), lastRefill: time.Now()} | |
| 128 | + cs.nicks[nick] = ns | |
| 129 | + } | |
| 130 | + | |
| 131 | + ns.recentMsgs = append(ns.recentMsgs, text) | |
| 132 | + // Keep last 10 messages. | |
| 133 | + if len(ns.recentMsgs) > 10 { | |
| 134 | + ns.recentMsgs = ns.recentMsgs[len(ns.recentMsgs)-10:] | |
| 135 | + } | |
| 136 | + | |
| 137 | + // Check for repetition: same message 3+ times in last 10. | |
| 138 | + count := 0 | |
| 139 | + for _, m := range ns.recentMsgs { | |
| 140 | + if m == text { | |
| 141 | + count++ | |
| 142 | + } | |
| 143 | + } | |
| 144 | + return count >= 3 | |
| 145 | +} | |
| 146 | + | |
| 147 | +// recordChannelMessage tracks messages at channel level for ping-pong detection. | |
| 148 | +// Returns the offending nick if a ping-pong loop is detected (two nicks | |
| 149 | +// alternating back and forth 4+ times with no other participants). | |
| 150 | +func (cs *channelState) recordChannelMessage(nick, text string) string { | |
| 151 | + cs.mu.Lock() | |
| 152 | + defer cs.mu.Unlock() | |
| 153 | + | |
| 154 | + cs.recentMsgs = append(cs.recentMsgs, channelMsg{nick: nick, text: text}) | |
| 155 | + if len(cs.recentMsgs) > 20 { | |
| 156 | + cs.recentMsgs = cs.recentMsgs[len(cs.recentMsgs)-20:] | |
| 157 | + } | |
| 158 | + | |
| 159 | + // Check last 8 messages for A-B-A-B pattern. | |
| 160 | + msgs := cs.recentMsgs | |
| 161 | + if len(msgs) < 8 { | |
| 162 | + return "" | |
| 163 | + } | |
| 164 | + tail := msgs[len(msgs)-8:] | |
| 165 | + nickA := tail[0].nick | |
| 166 | + nickB := tail[1].nick | |
| 167 | + if nickA == nickB { | |
| 168 | + return "" | |
| 169 | + } | |
| 170 | + for i, m := range tail { | |
| 171 | + expected := nickA | |
| 172 | + if i%2 == 1 { | |
| 173 | + expected = nickB | |
| 174 | + } | |
| 175 | + if m.nick != expected { | |
| 176 | + return "" | |
| 177 | + } | |
| 178 | + } | |
| 179 | + // A-B-A-B-A-B-A-B pattern detected — return the most recent speaker. | |
| 180 | + return nick | |
| 181 | +} | |
| 109 | 182 | |
| 110 | 183 | // violation records an enforcement action and returns the appropriate Action. |
| 111 | 184 | // Escalates: warn → mute → kick. Resets after CoolDown. |
| 112 | 185 | func (cs *channelState) violation(nick string) Action { |
| 113 | 186 | cs.mu.Lock() |
| @@ -276,10 +349,22 @@ | ||
| 276 | 349 | |
| 277 | 350 | // Skip enforcement for channel ops (+o and above). |
| 278 | 351 | if isChannelOp(cl, channel, nick) { |
| 279 | 352 | return |
| 280 | 353 | } |
| 354 | + | |
| 355 | + // Loop detection: same message repeated 3+ times → mute. | |
| 356 | + if cs.recordMessage(nick, text) { | |
| 357 | + b.enforce(cl, channel, nick, ActionMute, "repetitive message loop detected") | |
| 358 | + return | |
| 359 | + } | |
| 360 | + | |
| 361 | + // Ping-pong detection: two agents alternating back and forth → mute the latest. | |
| 362 | + if loopNick := cs.recordChannelMessage(nick, text); loopNick != "" { | |
| 363 | + b.enforce(cl, channel, loopNick, ActionMute, "agent ping-pong loop detected") | |
| 364 | + return | |
| 365 | + } | |
| 281 | 366 | |
| 282 | 367 | // Rate limit check. |
| 283 | 368 | if !cs.consume(nick) { |
| 284 | 369 | action := cs.violation(nick) |
| 285 | 370 | b.enforce(cl, channel, nick, action, "rate limit exceeded") |
| 286 | 371 |
| --- internal/bots/warden/warden.go | |
| +++ internal/bots/warden/warden.go | |
| @@ -63,17 +63,26 @@ | |
| 63 | type nickState struct { |
| 64 | tokens float64 |
| 65 | lastRefill time.Time |
| 66 | violations int |
| 67 | lastAction time.Time |
| 68 | } |
| 69 | |
| 70 | // channelState holds per-channel warden state. |
| 71 | type channelState struct { |
| 72 | mu sync.Mutex |
| 73 | cfg ChannelConfig |
| 74 | nicks map[string]*nickState |
| 75 | } |
| 76 | |
| 77 | func newChannelState(cfg ChannelConfig) *channelState { |
| 78 | cfg.defaults() |
| 79 | return &channelState{cfg: cfg, nicks: make(map[string]*nickState)} |
| @@ -104,10 +113,74 @@ | |
| 104 | ns.tokens-- |
| 105 | return true |
| 106 | } |
| 107 | return false |
| 108 | } |
| 109 | |
| 110 | // violation records an enforcement action and returns the appropriate Action. |
| 111 | // Escalates: warn → mute → kick. Resets after CoolDown. |
| 112 | func (cs *channelState) violation(nick string) Action { |
| 113 | cs.mu.Lock() |
| @@ -276,10 +349,22 @@ | |
| 276 | |
| 277 | // Skip enforcement for channel ops (+o and above). |
| 278 | if isChannelOp(cl, channel, nick) { |
| 279 | return |
| 280 | } |
| 281 | |
| 282 | // Rate limit check. |
| 283 | if !cs.consume(nick) { |
| 284 | action := cs.violation(nick) |
| 285 | b.enforce(cl, channel, nick, action, "rate limit exceeded") |
| 286 |
| --- internal/bots/warden/warden.go | |
| +++ internal/bots/warden/warden.go | |
| @@ -63,17 +63,26 @@ | |
| 63 | type nickState struct { |
| 64 | tokens float64 |
| 65 | lastRefill time.Time |
| 66 | violations int |
| 67 | lastAction time.Time |
| 68 | // Loop detection: track recent messages for repetition. |
| 69 | recentMsgs []string |
| 70 | } |
| 71 | |
| 72 | // channelMsg is a recent message for ping-pong detection. |
| 73 | type channelMsg struct { |
| 74 | nick string |
| 75 | text string |
| 76 | } |
| 77 | |
| 78 | // channelState holds per-channel warden state. |
| 79 | type channelState struct { |
| 80 | mu sync.Mutex |
| 81 | cfg ChannelConfig |
| 82 | nicks map[string]*nickState |
| 83 | recentMsgs []channelMsg // channel-wide message history for ping-pong detection |
| 84 | } |
| 85 | |
| 86 | func newChannelState(cfg ChannelConfig) *channelState { |
| 87 | cfg.defaults() |
| 88 | return &channelState{cfg: cfg, nicks: make(map[string]*nickState)} |
| @@ -104,10 +113,74 @@ | |
| 113 | ns.tokens-- |
| 114 | return true |
| 115 | } |
| 116 | return false |
| 117 | } |
| 118 | |
| 119 | // recordMessage tracks a message for loop detection. Returns true if a loop |
| 120 | // is detected (same message repeated 3+ times in recent history). |
| 121 | func (cs *channelState) recordMessage(nick, text string) bool { |
| 122 | cs.mu.Lock() |
| 123 | defer cs.mu.Unlock() |
| 124 | |
| 125 | ns, ok := cs.nicks[nick] |
| 126 | if !ok { |
| 127 | ns = &nickState{tokens: float64(cs.cfg.Burst), lastRefill: time.Now()} |
| 128 | cs.nicks[nick] = ns |
| 129 | } |
| 130 | |
| 131 | ns.recentMsgs = append(ns.recentMsgs, text) |
| 132 | // Keep last 10 messages. |
| 133 | if len(ns.recentMsgs) > 10 { |
| 134 | ns.recentMsgs = ns.recentMsgs[len(ns.recentMsgs)-10:] |
| 135 | } |
| 136 | |
| 137 | // Check for repetition: same message 3+ times in last 10. |
| 138 | count := 0 |
| 139 | for _, m := range ns.recentMsgs { |
| 140 | if m == text { |
| 141 | count++ |
| 142 | } |
| 143 | } |
| 144 | return count >= 3 |
| 145 | } |
| 146 | |
| 147 | // recordChannelMessage tracks messages at channel level for ping-pong detection. |
| 148 | // Returns the offending nick if a ping-pong loop is detected (two nicks |
| 149 | // alternating back and forth 4+ times with no other participants). |
| 150 | func (cs *channelState) recordChannelMessage(nick, text string) string { |
| 151 | cs.mu.Lock() |
| 152 | defer cs.mu.Unlock() |
| 153 | |
| 154 | cs.recentMsgs = append(cs.recentMsgs, channelMsg{nick: nick, text: text}) |
| 155 | if len(cs.recentMsgs) > 20 { |
| 156 | cs.recentMsgs = cs.recentMsgs[len(cs.recentMsgs)-20:] |
| 157 | } |
| 158 | |
| 159 | // Check last 8 messages for A-B-A-B pattern. |
| 160 | msgs := cs.recentMsgs |
| 161 | if len(msgs) < 8 { |
| 162 | return "" |
| 163 | } |
| 164 | tail := msgs[len(msgs)-8:] |
| 165 | nickA := tail[0].nick |
| 166 | nickB := tail[1].nick |
| 167 | if nickA == nickB { |
| 168 | return "" |
| 169 | } |
| 170 | for i, m := range tail { |
| 171 | expected := nickA |
| 172 | if i%2 == 1 { |
| 173 | expected = nickB |
| 174 | } |
| 175 | if m.nick != expected { |
| 176 | return "" |
| 177 | } |
| 178 | } |
| 179 | // A-B-A-B-A-B-A-B pattern detected — return the most recent speaker. |
| 180 | return nick |
| 181 | } |
| 182 | |
| 183 | // violation records an enforcement action and returns the appropriate Action. |
| 184 | // Escalates: warn → mute → kick. Resets after CoolDown. |
| 185 | func (cs *channelState) violation(nick string) Action { |
| 186 | cs.mu.Lock() |
| @@ -276,10 +349,22 @@ | |
| 349 | |
| 350 | // Skip enforcement for channel ops (+o and above). |
| 351 | if isChannelOp(cl, channel, nick) { |
| 352 | return |
| 353 | } |
| 354 | |
| 355 | // Loop detection: same message repeated 3+ times → mute. |
| 356 | if cs.recordMessage(nick, text) { |
| 357 | b.enforce(cl, channel, nick, ActionMute, "repetitive message loop detected") |
| 358 | return |
| 359 | } |
| 360 | |
| 361 | // Ping-pong detection: two agents alternating back and forth → mute the latest. |
| 362 | if loopNick := cs.recordChannelMessage(nick, text); loopNick != "" { |
| 363 | b.enforce(cl, channel, loopNick, ActionMute, "agent ping-pong loop detected") |
| 364 | return |
| 365 | } |
| 366 | |
| 367 | // Rate limit check. |
| 368 | if !cs.consume(nick) { |
| 369 | action := cs.violation(nick) |
| 370 | b.enforce(cl, channel, nick, action, "rate limit exceeded") |
| 371 |