ScuttleBot

Merge pull request #151 from ConflictHQ/feature/agent-loop-detection feat: agent loop detection — repetitive and ping-pong circuit breakers

noreply 2026-04-05 18:52 trunk merge
Commit a2b9161ba6ac0b74b46417218f1694f07ba168c80eaf843b171d3bc5fad5ea63
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -63,17 +63,26 @@
6363
type nickState struct {
6464
tokens float64
6565
lastRefill time.Time
6666
violations int
6767
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
6876
}
6977
7078
// channelState holds per-channel warden state.
7179
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
7584
}
7685
7786
func newChannelState(cfg ChannelConfig) *channelState {
7887
cfg.defaults()
7988
return &channelState{cfg: cfg, nicks: make(map[string]*nickState)}
@@ -104,10 +113,74 @@
104113
ns.tokens--
105114
return true
106115
}
107116
return false
108117
}
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
+}
109182
110183
// violation records an enforcement action and returns the appropriate Action.
111184
// Escalates: warn → mute → kick. Resets after CoolDown.
112185
func (cs *channelState) violation(nick string) Action {
113186
cs.mu.Lock()
@@ -276,10 +349,22 @@
276349
277350
// Skip enforcement for channel ops (+o and above).
278351
if isChannelOp(cl, channel, nick) {
279352
return
280353
}
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
+ }
281366
282367
// Rate limit check.
283368
if !cs.consume(nick) {
284369
action := cs.violation(nick)
285370
b.enforce(cl, channel, nick, action, "rate limit exceeded")
286371
--- 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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button