Skip to content

Commit 9ca3afb

Browse files
committed
docs: add bot-to-bot communication section to multi-agent.md
1 parent 4115f19 commit 9ca3afb

2 files changed

Lines changed: 125 additions & 15 deletions

File tree

docs/multi-agent.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,77 @@ See individual agent docs for authentication steps:
5151
- [Claude Code](claude-code.md)
5252
- [Codex](codex.md)
5353
- [Gemini](gemini.md)
54+
55+
## Bot-to-Bot Communication
56+
57+
By default, each agent ignores messages from other bots. To enable multi-agent collaboration in the same channel (e.g. a code review bot handing off to a deploy bot), configure `allow_bot_messages` in each agent's `config.toml`:
58+
59+
```toml
60+
[discord]
61+
allow_bot_messages = "mentions" # recommended
62+
```
63+
64+
### Modes
65+
66+
| Value | Behavior | Loop risk |
67+
|---|---|---|
68+
| `"off"` (default) | Ignore all bot messages | None |
69+
| `"mentions"` | Only respond to bot messages that @mention this bot | Very low — bots must explicitly @mention each other |
70+
| `"all"` | Respond to all bot messages | Mitigated by turn cap (10 consecutive bot messages) |
71+
72+
### Which mode should I use?
73+
74+
**`"mentions"` is recommended for most setups.** It enables collaboration while acting as a natural loop breaker — Bot A only processes Bot B's message if Bot B explicitly @mentions Bot A. Two bots won't accidentally ping-pong.
75+
76+
Use `"all"` only when bots need to react to each other's messages without explicit mentions (e.g. monitoring bots). A hard cap of 10 consecutive bot-to-bot turns prevents infinite loops.
77+
78+
### Example: Code Review → Deploy handoff
79+
80+
```
81+
┌──────────────────────────────────────────────────────────┐
82+
│ Discord Channel #dev │
83+
│ │
84+
│ 👤 User: "Review this PR and deploy if it looks good" │
85+
│ │ │
86+
│ ▼ │
87+
│ 🤖 Kiro (allow_bot_messages = "off"): │
88+
│ "LGTM — tests pass, no security issues. │
89+
│ @DeployBot please deploy to staging." │
90+
│ │ │
91+
│ ▼ │
92+
│ 🤖 Deploy Bot (allow_bot_messages = "mentions"): │
93+
│ "Deploying to staging... ✅ Done." │
94+
└──────────────────────────────────────────────────────────┘
95+
```
96+
97+
Note: the review bot doesn't need `allow_bot_messages` enabled — only the bot that needs to *receive* bot messages does.
98+
99+
### Helm values
100+
101+
```bash
102+
helm install openab openab/openab \
103+
--set agents.kiro.discord.botToken="$KIRO_BOT_TOKEN" \
104+
--set agents.kiro.discord.allowBotMessages="off" \
105+
--set agents.deploy.discord.botToken="$DEPLOY_BOT_TOKEN" \
106+
--set agents.deploy.discord.allowBotMessages="mentions"
107+
```
108+
109+
### Safety
110+
111+
- The bot's own messages are **always** ignored, regardless of setting
112+
- `"mentions"` mode is a natural loop breaker — no rate limiter needed
113+
- `"all"` mode has a hard cap of 10 consecutive bot-to-bot turns per channel
114+
- Channel and user allowlists still apply to bot messages
115+
- `trusted_bot_ids` further restricts which bots are allowed through
116+
117+
### Restricting to specific bots
118+
119+
If you only want to accept messages from specific bots (e.g. your own deploy bot), add their Discord user IDs:
120+
121+
```toml
122+
[discord]
123+
allow_bot_messages = "mentions"
124+
trusted_bot_ids = ["123456789012345678"] # only this bot's messages pass through
125+
```
126+
127+
When `trusted_bot_ids` is empty (default), any bot can pass through (subject to the mode check). When set, only listed bots are accepted — all others are silently ignored.

src/discord.rs

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ use std::sync::Arc;
1818
use tokio::sync::watch;
1919
use tracing::{debug, error, info};
2020

21-
/// Hard cap on consecutive bot-to-bot turns per thread/channel.
22-
/// Prevents infinite loops when `allow_bot_messages = "all"`.
21+
/// Hard cap on consecutive bot messages (from any other bot) in a
22+
/// channel or thread. When this many recent messages are all from
23+
/// bots other than ourselves, we stop responding to prevent runaway
24+
/// loops between multiple bots in "all" mode.
25+
///
26+
/// Note: must be ≤ 255 because Serenity's `GetMessages::limit()` takes `u8`.
2327
/// Inspired by OpenClaw's `session.agentToAgent.maxPingPongTurns`.
24-
const MAX_BOT_TURNS_PER_THREAD: usize = 10;
28+
const MAX_CONSECUTIVE_BOT_TURNS: u8 = 10;
2529

2630
/// Reusable HTTP client for downloading Discord attachments.
2731
/// Built once with a 30s timeout and rustls TLS (no native-tls deps).
@@ -69,19 +73,51 @@ impl EventHandler for Handler {
6973
AllowBots::Off => return,
7074
AllowBots::Mentions => if !is_mentioned { return; },
7175
AllowBots::All => {
72-
// Safety net: cap consecutive bot messages to prevent
73-
// infinite loops when two bots both use "all" mode.
74-
if let Ok(history) = msg.channel_id
75-
.messages(&ctx.http, serenity::builder::GetMessages::new().before(msg.id).limit(MAX_BOT_TURNS_PER_THREAD as u8))
76-
.await
77-
{
78-
let consecutive_bot = history.iter()
79-
.take_while(|m| m.author.bot && m.author.id != bot_id)
80-
.count();
81-
if consecutive_bot >= MAX_BOT_TURNS_PER_THREAD {
82-
tracing::warn!(channel_id = %msg.channel_id, cap = MAX_BOT_TURNS_PER_THREAD, "bot turn cap reached, ignoring");
83-
return;
76+
// Safety net: count consecutive messages from any bot
77+
// (excluding ourselves) in recent history. If all recent
78+
// messages are from other bots, we've likely entered a
79+
// loop. This counts *all* other-bot messages, not just
80+
// one specific bot — so 3 bots taking turns still hits
81+
// the cap (which is intentionally conservative).
82+
//
83+
// Try cache first to avoid an API call on every bot
84+
// message. Fall back to API on cache miss. If both fail,
85+
// reject the message (fail-closed) to avoid unbounded
86+
// loops during Discord API outages.
87+
let cap = MAX_CONSECUTIVE_BOT_TURNS as usize;
88+
let history = ctx.cache.channel_messages(msg.channel_id)
89+
.map(|msgs| {
90+
let mut recent: Vec<_> = msgs.iter()
91+
.filter(|(mid, _)| **mid < msg.id)
92+
.map(|(_, m)| m.clone())
93+
.collect();
94+
recent.sort_unstable_by(|a, b| b.id.cmp(&a.id)); // newest first
95+
recent.truncate(cap);
96+
recent
97+
})
98+
.filter(|msgs| !msgs.is_empty());
99+
100+
let recent = if let Some(cached) = history {
101+
cached
102+
} else {
103+
match msg.channel_id
104+
.messages(&ctx.http, serenity::builder::GetMessages::new().before(msg.id).limit(MAX_CONSECUTIVE_BOT_TURNS))
105+
.await
106+
{
107+
Ok(msgs) => msgs,
108+
Err(e) => {
109+
tracing::warn!(channel_id = %msg.channel_id, error = %e, "failed to fetch history for bot turn cap, rejecting (fail-closed)");
110+
return;
111+
}
84112
}
113+
};
114+
115+
let consecutive_bot = recent.iter()
116+
.take_while(|m| m.author.bot && m.author.id != bot_id)
117+
.count();
118+
if consecutive_bot >= cap {
119+
tracing::warn!(channel_id = %msg.channel_id, cap, "bot turn cap reached, ignoring");
120+
return;
85121
}
86122
},
87123
}

0 commit comments

Comments
 (0)