Bot Loop Protection

Break runaway bot-to-bot reply loops with a sliding-window pair budget

BotLoopGuard breaks runaway bot-to-bot reply loops — two bots answering each other forever — by budgeting how many exchanges a pair may have inside a sliding window. Once a channel opts in with allow_bots: true, the guard is auto-instantiated per adapter — no code to wire.

yaml
# gateway.yaml
channels:
  telegram:
    token: ${TELEGRAM_BOT_TOKEN}
    allow_bots: true          # accept bot-authored inbound messages
    bot_loop_protection:      # break runaway A<->B reply loops
      max_events_per_window: 20

That’s it — the BotLoopGuard is auto-instantiated per adapter, and any A↔B pair that exceeds 20 exchanges in a 60s window is suppressed for a 60s cooldown.

Two bots ping-pong replies; the guard counts each exchange and, once a pair passes its budget, drops the turn and opens a cooldown.

Diagram

Under budget

Over budget

🤖 Bot A reply

🛡️ Pair budget?

🤖 Bot B reply

✅ Reply sent

⛔ Turn dropped

❄️ Cooldown

Quick Start

  1. Enable with allow_bots: true

    Flip the opt-in on the channel — everything else uses the default budget (20 exchanges / 60s window / 60s cooldown).

    yaml
    # gateway.yaml
    channels:
      telegram:
        token: ${TELEGRAM_BOT_TOKEN}
        allow_bots: true
  2. Tune the budget

    Add bot_loop_protection keys to change how quickly a loop trips and how long it stays quiet.

    yaml
    # gateway.yaml
    channels:
      telegram:
        token: ${TELEGRAM_BOT_TOKEN}
        allow_bots: true
        bot_loop_protection:
          max_events_per_window: 10
          window_seconds: 30
          cooldown_seconds: 120

    The same two fields exist on BotConfig for the Python constructor path:

    python
    from praisonaiagents import Agent
    from praisonaiagents.bots import BotConfig
    
    agent = Agent(
        name="Group Helper",
        instructions="Answer questions posted in the room.",
    )
    
    config = BotConfig(
        allow_bots=True,
        bot_loop_protection={
            "max_events_per_window": 10,
            "window_seconds": 30,
            "cooldown_seconds": 120,
        },
    )
  3. Advanced — wire the primitive yourself

    Custom adapters that do not inherit MessageHookMixin can call BotLoopGuard.observe(...) directly — this is the escape hatch, not the recommended path.

    python
    from praisonaiagents.bots import BotLoopGuard, BotLoopPolicy
    
    guard = BotLoopGuard(BotLoopPolicy(max_events_per_window=20, window_seconds=60))
    
    # In your bot inbound handler, before dispatching to the agent:
    if sender_is_bot and not guard.observe(
        self_bot_id="mybot",
        sender_bot_id=inbound_sender_id,
    ):
        return  # drop the turn — pair is over budget / in cooldown

How It Works

The guard tracks each bot pair in a sliding window; exchange #21 trips the budget and both bots go quiet for the cooldown.

Diagram
BotBGuardGatewayBotABotBGuardGatewayBotAloop[Under budget]budget exceededturn dropped, cooldown openreplyobserve(A, B)Truedispatchreplyobserve(B, A)Truereplyobserve(A, B)False

A → B and B → A collapse to the same pair key, so a ping-pong loop accrues one budget instead of two independent streams.

BehaviourEffect
Direction independenceA↔B and B↔A share one budget
Sliding windowEvents older than window_seconds are evicted before the budget check
CooldownOnce exceeded, the pair is dropped for cooldown_seconds without recording
Distinct pairsA↔B at budget does not affect A↔C

How adapters wire it

Every adapter inherits MessageHookMixin.bot_loop_allows(sender, self_bot_id=...), the shared helper that folds the allow_bots opt-in and the per-pair budget into one verdict — a human sender always passes, a bot sender passes only when allow_bots: true and the pair is under budget.

Diagram
BotLoopGuardbot_loop_allowsAdapterSenderBotLoopGuardbot_loop_allowsAdapterSenderalt[Human sender][Bot sender, allow_bots=False][Bot sender, allow_bots=True]inbound messagebot_loop_allows(sender, self_bot_id)True (no-op)False (drop)observe(self, sender)verdictTrue / False
AdapterWhere it callsSelf-message drop
Telegramshared process_inbound_telegram_message pipeline—
Slack_bot_sender_alloweddrops on the app’s bot_id (not user)
Discordon_messagedrops when message.author.id == self._client.user.id

When to Enable

BotLoopGuard only matters when your gateway accepts bot-authored inbound messages and multiple bots can share a room.

Diagram

No

Yes

No

Yes

Yes

No

Configure loop protection?

Gateway accepts
bot-authored inbound?

No guard needed

Multi-bot rooms
possible?

Set allow_bots: true

High-volume
workflow bots?

Raise max_events_per_window

Keep defaults


Configuration Options

Two BotConfig fields turn the guard on and shape its budget. Both match praisonaiagents/bots/config.py.

FieldTypeDefaultDescription
BotConfig.allow_botsboolFalseOpt-in for bot-authored inbound messages. Default-off drops every bot-authored inbound turn (unchanged from pre-#5242).
BotConfig.bot_loop_protection`dict \None`NoneSliding-window pair-budget policy. None uses the default (enabled=True, budget 20 per 60s, cooldown 60s).

bot_loop_protection accepts the same keys as BotLoopPolicy, coerced via BotLoopPolicy.from_dict(...). Every field matches praisonaiagents/bots/silence.py.

FieldTypeDefaultDescription
enabledboolTrueWhen False, the guard is a transparent no-op (always allows).
max_events_per_windowint20Max bot-authored exchanges per pair before cooldown opens. Clamped to >= 1.
window_secondsfloat60.0Length of the sliding window.
cooldown_secondsfloat60.0How long a pair stays suppressed once the budget is exceeded.

BotLoopPolicy.from_dict(data) builds a policy from a mapping; None returns the enabled default and unknown keys are ignored.

BotLoopPolicy.from_dict tolerates malformed numeric values — a typo like max_events_per_window: "abc" falls back to the field default instead of crashing the inbound path on the first bot message.

The runtime BotLoopGuard exposes this surface:

SymbolSignaturePurpose
ConstructorBotLoopGuard(policy: Optional[BotLoopPolicy] = None)Defaults to the enabled default policy; uses an internal lock for shared-state safety.
enabled@property -> boolWhether the guard actively suppresses loops.
observeobserve(*, self_bot_id: str, sender_bot_id: str, now: Optional[float] = None) -> boolRecords one exchange; returns True to allow or False to drop. Always True when disabled.
resetreset() -> NoneClears all tracked pairs and cooldowns.

Common Patterns

Once allow_bots: true is set the guard runs automatically; these primitive-level patterns cover custom adapters and tests.

Group room with multiple assistants — use one guard per gateway and call observe() only when the sender is a bot.

python
from praisonaiagents.bots import BotLoopGuard

guard = BotLoopGuard()

def on_inbound(msg, sender_is_bot):
    if sender_is_bot and not guard.observe(
        self_bot_id="mybot", sender_bot_id=msg.sender_id
    ):
        return  # loop budget hit — drop silently
    handle(msg)

Testing with an injected clock — pass explicit now= values for deterministic time-dependent tests.

python
from praisonaiagents.bots import BotLoopGuard, BotLoopPolicy

guard = BotLoopGuard(BotLoopPolicy(max_events_per_window=2, window_seconds=60.0))
assert guard.observe(self_bot_id="a", sender_bot_id="b", now=0.0)
assert guard.observe(self_bot_id="a", sender_bot_id="b", now=1.0)
assert not guard.observe(self_bot_id="a", sender_bot_id="b", now=2.0)

Resetting on gateway restart — call reset() after redeploy so leftover cooldowns from an earlier process don’t leak.

python
guard.reset()  # clear tracked pairs and cooldowns

When It Fires vs Doesn’t

Only bot-authored inbound turns reach the guard; humans and single-bot deployments never call observe().

Inbound senderBelow budgetAbove budget in cooldownAfter cooldown expires
HumanNot observed (always allowed)Not observedNot observed
Bot (different pair)AllowedAllowed (independent pair)Allowed
Bot (same pair)AllowedDroppedAllowed (fresh window)

Best Practices

Only observe bot-authored inbound turns

Skip observe() when the sender is human — the guard is zero-cost on the common path.

Size the window and cooldown separately

Use a shorter window_seconds with a higher cooldown_seconds for chatty rooms — the window sizes urgency, the cooldown sizes recovery.

Disable with enabled=False, not a zero budget

max_events_per_window=0 is clamped to 1 and hides intent. Set enabled=False to turn the guard off.

Share one guard per gateway process

The deque/dict state is per-instance and per-pair, so a single shared BotLoopGuard tracks the whole gateway.

Combine with Intentional Silence

Pair the guard with allow_silence and Intentional Silence for full ambient-channel safety — the agent chooses to stay quiet, and the guard stops runaway loops.


Related