@@ -228,7 +228,52 @@ impl EventHandler for Handler {
228228 async fn message ( & self , ctx : Context , msg : Message ) {
229229 let bot_id = ctx. cache . current_user ( ) . id ;
230230
231- // Always ignore own messages
231+ // Early multibot detection: cache that another bot is present.
232+ // Runs before self-check and bot gating so we always detect other bots. (#481)
233+ if msg. author . bot && msg. author . id != bot_id {
234+ let key = msg. channel_id . to_string ( ) ;
235+ let mut cache = self . multibot_threads . lock ( ) . await ;
236+ cache. entry ( key) . or_insert_with ( tokio:: time:: Instant :: now) ;
237+ }
238+
239+ // Bot turn counting: runs before self-check so ALL bot messages
240+ // (including own) count toward the per-thread limit. This means
241+ // soft_limit=20 = 20 total bot messages in the thread (~10 per bot
242+ // in a two-bot ping-pong). (#483)
243+ {
244+ let thread_key = msg. channel_id . to_string ( ) ;
245+ let mut tracker = self . bot_turns . lock ( ) . await ;
246+ if msg. author . bot {
247+ match tracker. on_bot_message ( & thread_key) {
248+ TurnResult :: HardLimit => {
249+ tracing:: warn!( channel_id = %msg. channel_id, "hard bot turn limit reached" ) ;
250+ // Only send the warning if we're not the one who caused it
251+ if msg. author . id != bot_id {
252+ let _ = msg. channel_id . say (
253+ & ctx. http ,
254+ format ! ( "🛑 Hard limit reached ({HARD_BOT_TURN_LIMIT}). Bot-to-bot conversation in this thread has been permanently stopped." ) ,
255+ ) . await ;
256+ }
257+ return ;
258+ }
259+ TurnResult :: SoftLimit ( n) => {
260+ tracing:: info!( channel_id = %msg. channel_id, turns = n, max = self . max_bot_turns, "soft bot turn limit reached" ) ;
261+ if msg. author . id != bot_id {
262+ let _ = msg. channel_id . say (
263+ & ctx. http ,
264+ format ! ( "⚠️ Bot turn limit reached ({n}/{}). A human must reply in this thread to continue bot-to-bot conversation." , self . max_bot_turns) ,
265+ ) . await ;
266+ }
267+ return ;
268+ }
269+ TurnResult :: Ok => { }
270+ }
271+ } else {
272+ tracker. on_human_message ( & thread_key) ;
273+ }
274+ }
275+
276+ // Ignore own messages (after counting toward bot turns above)
232277 if msg. author . id == bot_id {
233278 return ;
234279 }
@@ -244,15 +289,6 @@ impl EventHandler for Handler {
244289 let is_mentioned = msg. mentions_user_id ( bot_id)
245290 || msg. content . contains ( & format ! ( "<@{}>" , bot_id) ) ;
246291
247- // Early multibot detection: cache that another bot is present in this
248- // channel/thread. Runs BEFORE bot message gating so we detect other
249- // bots even when their messages are filtered out. (#481)
250- if msg. author . bot && msg. author . id != bot_id {
251- let key = msg. channel_id . to_string ( ) ;
252- let mut cache = self . multibot_threads . lock ( ) . await ;
253- cache. entry ( key) . or_insert_with ( tokio:: time:: Instant :: now) ;
254- }
255-
256292 // Bot message gating (from upstream #321)
257293 if msg. author . bot {
258294 match self . allow_bot_messages {
@@ -398,38 +434,6 @@ impl EventHandler for Handler {
398434
399435 let prompt = resolve_mentions ( & msg. content , bot_id) ;
400436
401- // Bot turn limiting: track consecutive bot turns per thread.
402- // Placed after all gating so only messages that will actually be
403- // processed count toward the limit.
404- // Human message resets both soft and hard counters.
405- {
406- let thread_key = msg. channel_id . to_string ( ) ;
407- let mut tracker = self . bot_turns . lock ( ) . await ;
408- if msg. author . bot {
409- match tracker. on_bot_message ( & thread_key) {
410- TurnResult :: HardLimit => {
411- tracing:: warn!( channel_id = %msg. channel_id, "hard bot turn limit reached" ) ;
412- let _ = msg. channel_id . say (
413- & ctx. http ,
414- format ! ( "🛑 Hard limit reached ({HARD_BOT_TURN_LIMIT}). Bot-to-bot conversation in this thread has been permanently stopped." ) ,
415- ) . await ;
416- return ;
417- }
418- TurnResult :: SoftLimit ( n) => {
419- tracing:: info!( channel_id = %msg. channel_id, turns = n, max = self . max_bot_turns, "soft bot turn limit reached" ) ;
420- let _ = msg. channel_id . say (
421- & ctx. http ,
422- format ! ( "⚠️ Bot turn limit reached ({n}/{}). A human must reply in this thread to continue bot-to-bot conversation." , self . max_bot_turns) ,
423- ) . await ;
424- return ;
425- }
426- TurnResult :: Ok => { }
427- }
428- } else {
429- tracker. on_human_message ( & thread_key) ;
430- }
431- }
432-
433437 // No text and no attachments → skip
434438 if prompt. is_empty ( ) && msg. attachments . is_empty ( ) {
435439 return ;
@@ -932,6 +936,40 @@ mod tests {
932936 t. on_human_message ( "unknown" ) ; // should not panic
933937 }
934938
939+ /// Two-bot ping-pong: both bots' messages count toward the same per-thread
940+ /// limit. With soft_limit=20, the limit triggers after 20 total bot messages
941+ /// (~10 per bot). This simulates what each bot's process sees when the
942+ /// tracker runs before self-check — own messages are counted too. (#483)
943+ #[ test]
944+ fn two_bot_pingpong_hits_soft_limit ( ) {
945+ let mut t = BotTurnTracker :: new ( 20 ) ;
946+ // Simulate 20 bot messages (alternating bot A and bot B,
947+ // but the tracker doesn't distinguish — it just counts)
948+ for i in 1 ..20 {
949+ assert_eq ! ( t. on_bot_message( "t1" ) , TurnResult :: Ok , "turn {i}" ) ;
950+ }
951+ assert_eq ! ( t. on_bot_message( "t1" ) , TurnResult :: SoftLimit ( 20 ) ) ;
952+ }
953+
954+ /// Human message in the middle of a ping-pong resets the counter,
955+ /// allowing bots to continue.
956+ #[ test]
957+ fn two_bot_pingpong_human_resets ( ) {
958+ let mut t = BotTurnTracker :: new ( 20 ) ;
959+ for _ in 0 ..15 {
960+ assert_eq ! ( t. on_bot_message( "t1" ) , TurnResult :: Ok ) ;
961+ }
962+ t. on_human_message ( "t1" ) ; // human intervenes at 15
963+ for _ in 0 ..15 {
964+ assert_eq ! ( t. on_bot_message( "t1" ) , TurnResult :: Ok ) ; // can do 15 more
965+ }
966+ // now at 15 again, 5 more to hit limit
967+ for _ in 0 ..4 {
968+ assert_eq ! ( t. on_bot_message( "t1" ) , TurnResult :: Ok ) ;
969+ }
970+ assert_eq ! ( t. on_bot_message( "t1" ) , TurnResult :: SoftLimit ( 20 ) ) ;
971+ }
972+
935973 // --- resolve_mentions tests ---
936974
937975 /// Bot's own <@UID> mention is stripped from the prompt.
0 commit comments