Skip to content
Merged
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
12 changes: 12 additions & 0 deletions docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,18 @@ To enable bots to collaborate (e.g. code review → deploy handoff):
allow_bot_messages = "mentions"
```

### Bot turn limits

To prevent runaway bot-to-bot loops, OpenAB enforces two layers of protection:

- **Soft limit** (`max_bot_turns`, default: 20) — consecutive bot turns without human intervention. When reached, the bot sends a warning and stops responding. A human message in the thread resets the counter.
- **Hard limit** (100, not configurable) — absolute cap on total bot turns per thread. When reached, bot-to-bot conversation is permanently stopped in that thread.

```toml
[discord]
max_bot_turns = 30 # default is 20
```

### Ice-breaking: teaching bots who's in the room

Since user mentions are preserved as raw `<@UID>`, bots need a UID→name mapping to know who is who. Add an ice-breaking greeting to each bot's system prompt or context entry:
Expand Down
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,14 @@ pub struct DiscordConfig {
pub trusted_bot_ids: Vec<String>,
#[serde(default)]
pub allow_user_messages: AllowUsers,
/// Max consecutive bot turns (without human intervention) before throttling.
/// Human message resets the counter. Default: 20.
#[serde(default = "default_max_bot_turns")]
pub max_bot_turns: u32,
}

fn default_max_bot_turns() -> u32 { 20 }

/// Controls whether the bot responds to user messages in threads without @mention.
///
/// - `Involved` (default): respond to thread messages only if the bot has participated
Expand Down
60 changes: 54 additions & 6 deletions src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ use tracing::{debug, error, info};
/// Prevents runaway loops between multiple bots in "all" mode.
const MAX_CONSECUTIVE_BOT_TURNS: u8 = 10;

/// Absolute per-thread cap on bot turns. Cannot be overridden by config or human intervention.
const HARD_BOT_TURN_LIMIT: u32 = 100;

/// Maximum entries in the participation cache before eviction.
const PARTICIPATION_CACHE_MAX: usize = 1000;

Expand Down Expand Up @@ -121,6 +124,10 @@ pub struct Handler {
pub multibot_threads: tokio::sync::Mutex<HashMap<String, tokio::time::Instant>>,
/// TTL for participation cache entries (from pool.session_ttl_hours).
pub session_ttl: std::time::Duration,
/// Configurable soft limit on bot turns per thread (reset by human message).
pub max_bot_turns: u32,
/// Per-thread counters: (soft_turns, hard_turns). Soft resets on human msg, hard never resets.
pub bot_turn_counts: tokio::sync::Mutex<HashMap<String, (u32, u32)>>,
}

impl Handler {
Expand Down Expand Up @@ -148,12 +155,10 @@ impl Handler {
};

// Both cached → skip fetch entirely
if cached_involved && cached_multibot {
return (true, true);
}
// Involved cached + not MultibotMentions mode → don't need other_bot info
if cached_involved && self.allow_user_messages != AllowUsers::MultibotMentions {
return (true, false);
// With early detection from msg.author, multibot_threads is populated
// eagerly — no need to fetch just to check for other bots.
if cached_involved {
return (true, cached_multibot);
}

// Fetch recent messages
Expand Down Expand Up @@ -321,6 +326,14 @@ impl EventHandler for Handler {
return;
}

// Early multibot detection: if the current message is from another bot,
// this thread is multi-bot. Cache it now — no fetch needed.
if in_thread && msg.author.bot && msg.author.id != bot_id {
let key = msg.channel_id.to_string();
let mut cache = self.multibot_threads.lock().await;
cache.entry(key).or_insert_with(tokio::time::Instant::now);
}

// User message gating (mirrors Slack's AllowUsers logic).
// Mentions: always require @mention, even in bot's own threads.
// Involved (default): skip @mention if the bot owns the thread
Expand Down Expand Up @@ -380,6 +393,41 @@ impl EventHandler for Handler {

let prompt = resolve_mentions(&msg.content, bot_id);

// Bot turn limiting: track consecutive bot turns per thread.
// Placed after all gating so only messages that will actually be
// processed count toward the limit.
// Human message resets soft counter; hard counter never resets.
{
let thread_key = msg.channel_id.to_string();
let mut counts = self.bot_turn_counts.lock().await;
if msg.author.bot {
let (soft, hard) = counts.entry(thread_key).or_insert((0, 0));
*soft += 1;
*hard += 1;
if *hard >= HARD_BOT_TURN_LIMIT {
tracing::warn!(channel_id = %msg.channel_id, hard = *hard, "hard bot turn limit reached");
let _ = msg.channel_id.say(
&ctx.http,
format!("🛑 Hard limit reached ({HARD_BOT_TURN_LIMIT}). Bot-to-bot conversation in this thread has been permanently stopped."),
).await;
return;
}
if *soft >= self.max_bot_turns {
tracing::info!(channel_id = %msg.channel_id, soft = *soft, max = self.max_bot_turns, "soft bot turn limit reached");
let _ = msg.channel_id.say(
&ctx.http,
format!("⚠️ Bot turn limit reached ({}/{}). A human must reply in this thread to continue bot-to-bot conversation.", *soft, self.max_bot_turns),
).await;
return;
}
} else {
// Human message: reset soft counter
if let Some((soft, _)) = counts.get_mut(&thread_key) {
*soft = 0;
}
}
}

// No text and no attachments → skip
if prompt.is_empty() && msg.attachments.is_empty() {
return;
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ async fn main() -> anyhow::Result<()> {
participated_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()),
multibot_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()),
session_ttl: std::time::Duration::from_secs(ttl_secs),
max_bot_turns: discord_cfg.max_bot_turns,
bot_turn_counts: tokio::sync::Mutex::new(std::collections::HashMap::new()),
};

let intents = GatewayIntents::GUILD_MESSAGES
Expand Down
Loading