Skip to content
Open
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
424 changes: 272 additions & 152 deletions crates/openab-core/src/adapter.rs

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,9 +609,10 @@ pub struct GatewayConfig {
/// by default**. By default send-once delivers **only the final answer block** — the text after
/// the last tool call — dropping inter-tool narration (the shared default send-once trimming in
/// `AdapterRouter::stream_prompt_blocks`, controlled by the platform-agnostic
/// `[reactions] narration_display`). Discord is likewise send-once in multi-bot threads
/// (`use_streaming` = `!other_bot_present`) and gets the same default trimming. Set `true` to
/// stream live and keep the full inter-tool text.
/// `[reactions] narration_display`). Discord keeps sanitized presentation
/// streaming enabled even in multi-bot threads; this setting controls the
/// gateway adapter's streaming behavior. Set `true` to stream live and keep
/// the full inter-tool text on gateway platforms.
#[serde(default)]
pub streaming: bool,
/// Show "…" placeholder at streaming start. Default: true. Set false for platforms using drafts.
Expand Down
466 changes: 448 additions & 18 deletions crates/openab-core/src/discord.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Discord adapter. Requires a Discord bot token.
| `allow_all_users` | bool \| omit | auto-detect | `true` = any user; `false` = only `allowed_users`. Omitted = inferred from list. |
| `allowed_users` | string[] | `[]` | User IDs to allow. Only checked when `allow_all_users` resolves to false. |
| `allow_bot_messages` | string | `"off"` | `"off"` — ignore all bot messages. `"mentions"` — only process bot messages that @mention this bot. `"all"` — process all bot messages (capped by `max_bot_turns`). |
| `trusted_bot_ids` | string[] | `[]` | When non-empty, only these bot IDs pass the bot gate. Empty = any bot (mode permitting). **Admission override:** a trusted bot that @mentions this bot bypasses `allow_bot_messages` mode entirely (treated as human @mention, can pull bot into threads). |
| `trusted_bot_ids` | string[] | `[]` | When non-empty, only these bot IDs pass the bot gate. Empty = any bot (mode permitting). **Admission override:** a trusted bot that @mentions this bot bypasses `allow_bot_messages` mode entirely (treated as human @mention, can pull bot into threads). For Discord structured handoffs, this list is also the outbound target allowlist: `[[handoff:BOT_ID]]` fails closed unless `BOT_ID` is listed. |
| `allow_user_messages` | string | `"multibot-mentions"` | `"multibot-mentions"` — like `"involved"`, but require @mention once another bot has posted in the thread (recommended for multi-bot deployments). `"involved"` — reply in threads bot has participated in without @mention; channel messages require @mention; DMs always process. `"mentions"` — always require @mention. |
| `allow_dm` | bool | `false` | `true` = respond to Discord DMs; `false` = ignore DMs. `allowed_users` still applies in DMs. Each DM user consumes one session slot. |
| `max_bot_turns` | u32 | `100` | Max consecutive bot turns per thread before throttling (soft limit). Human message resets the counter. A compiled-in hard cap of 1000 consecutive bot messages is always enforced. |
Expand Down
27 changes: 16 additions & 11 deletions docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,16 +263,16 @@ No configuration is needed — video forwarding is always enabled.

OpenAB uses **edit-streaming** on Discord — the bot sends a placeholder message and updates it every 1.5 seconds as tokens arrive, giving a live typing effect.

Streaming is decided **per-thread**, not globally:
Streaming remains enabled during multi-bot conversations so long-running work stays visible:

| Thread state | Streaming |
|---|---|
| Single bot + human | ✅ ON — live edit updates |
| 2+ bots in thread | ❌ OFFsend-once to avoid edit interference |
| 2+ bots in thread | ✅ ONeach bot updates its own presentation message |

When a second bot posts in a thread, streaming automatically switches off for that thread. This prevents multiple bots from editing placeholder messages simultaneously, which causes visual glitches on Discord.
Presentation output is sanitized to prevent ordinary model text from notifying arbitrary Discord users, roles, or everyone. The structured handoff path is separate: an output directive such as `[[handoff:123456789012345678]]` emits a human-visible control message only when the target is in the configured `trusted_bot_ids` allowlist. The handoff target receives the raw structured payload; the human-facing presentation remains sanitized.

No configuration needed — this is automatic based on multibot detection.
No configuration is needed for streaming. Configure `trusted_bot_ids` when enabling bot-to-bot handoffs so outbound targets are explicitly authorized.

---

Expand Down Expand Up @@ -344,8 +344,13 @@ To enable bots to collaborate (e.g. code review → deploy handoff):
# Bot that receives bot messages
[discord]
allow_bot_messages = "mentions"
trusted_bot_ids = ["123456789012345678"] # explicit outbound/inbound bot allowlist
```

For a structured handoff, the producing agent emits `[[handoff:<TARGET_BOT_DISCORD_ID>]]` in its output. The Discord adapter only sends that control message when the target ID is in `trusted_bot_ids` and is not the current bot. Ordinary presentation text is kept human-readable but its Discord mentions are suppressed; do not use a plain `@BotName` sentence as the handoff protocol.

A handoff control message is visible in the channel because Discord does not provide a hidden bot-only message type. Its payload is accepted only after schema, source, target, channel/thread provenance, expiry, hop, replay, and rate-limit checks. A bot-authored message that matches the envelope shape but fails validation is dropped rather than passed to the normal ACP prompt path; this fail-closed behavior prevents malformed control input from becoming ordinary agent input.

### Bot turn limits

To prevent runaway bot-to-bot loops, OpenAB enforces two layers of protection:
Expand All @@ -362,21 +367,21 @@ Warning messages are sent exactly once (on the exact threshold hit) to prevent w
max_bot_turns = 200 # default is 100
```

### Ice-breaking: teaching bots who's in the room
### Ice-breaking: identifying bots 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:
Bots can still include Discord IDs in their context to identify participants, but ordinary presentation mentions are sanitized and do not notify arbitrary users or roles. For bot-to-bot routing, use the structured directive and configure an explicit target allowlist instead of relying on a plain `@BotName` sentence:

```
We have 3 participants in this room:

MY_NICIKNAME <@MY_NAME>
BOT1_NICKNAME <@BOT1>
BOT2_NICKNAME <@BOT2>
MY_NICKNAME <@MY_ID>
BOT1_NICKNAME <@BOT1_ID>
BOT2_NICKNAME <@BOT2_ID>

Always use <@UID> format to mention someone in your messages.
For a structured handoff, emit [[handoff:<TARGET_BOT_ID>]] only for a configured trusted bot.
```

This lets each bot build the mapping in its own context from the start and correctly mention others using `<@UID>`.
The target ID must be listed in `trusted_bot_ids` on the sending adapter. This makes the routing decision explicit and prevents model-generated numeric IDs from becoming arbitrary notification targets.

See [multi-agent.md](multi-agent.md) for detailed examples.

Expand Down
26 changes: 11 additions & 15 deletions docs/multi-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,24 +78,20 @@ Use `"all"` only when bots need to react to each other's messages without explic

### Example: Code Review → Deploy handoff

The review agent can request a structured handoff by emitting the target deploy bot's Discord ID. Configure the same trusted IDs on the participating bots:

```toml
[discord]
allow_bot_messages = "mentions"
trusted_bot_ids = ["123456789012345678"]
```
┌──────────────────────────────────────────────────────────┐
│ Discord Channel #dev │
│ │
│ 👤 User: "Review this PR and deploy if it looks good" │
│ │ │
│ ▼ │
│ 🤖 Kiro (allow_bot_messages = "off"): │
│ "LGTM — tests pass, no security issues. │
│ @DeployBot please deploy to staging." │
│ │ │
│ ▼ │
│ 🤖 Deploy Bot (allow_bot_messages = "mentions"): │
│ "Deploying to staging... ✅ Done." │
└──────────────────────────────────────────────────────────┘

```text
[[handoff:123456789012345678]]
Review complete: tests pass and no security issues found.
```

Note: the review bot doesn't need `allow_bot_messages` enabled — only the bot that needs to *receive* bot messages does.
The adapter turns the directive into a structured control message addressed to the allowlisted target. The target bot validates the envelope before placing its raw payload in the ACP prompt, while the accompanying presentation remains visible to humans with ordinary Discord mentions suppressed. If the target is not allowlisted, the directive fails closed and only the sanitized presentation is delivered.

### Helm values

Expand Down
11 changes: 11 additions & 0 deletions docs/output-directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ Here is my reply to that specific message.
- Invalid/non-existent message ID: silently falls back to plain send
- Works in both streaming and send-once modes

### `handoff` (Discord only)

Request a structured bot-to-bot handoff after the current ACP turn completes:

```
[[handoff:123456789012345678]]
Review complete; deploy this change to staging.
```

**Value**: a decimal Discord bot snowflake (`u64`, at most 20 digits). The directive is stripped from presentation output; directive-shaped `handoff` lines with invalid values are also consumed and ignored, with a debug trace, rather than being forwarded as content. On Discord, OpenAB emits a structured control message only when the target ID is present in `trusted_bot_ids` and is not the sending bot; otherwise it fails closed and delivers only the sanitized presentation. The control payload is subject to Discord's 2,000 UTF-16-code-unit message limit, including the target mention and JSON envelope, so practical payload capacity is lower than 2,000 characters. Receiving bots additionally enforce a 100,000-byte `payload.text` defense-in-depth bound, then validate schema, trust, target, provenance, expiry, hop count, replay, and rate before dispatching it to ACP.

**How agents get message IDs**: Every incoming message includes `message_id` in `SenderContext`:

```json
Expand Down
8 changes: 4 additions & 4 deletions docs/platforms/schema/discord.toml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ pr = ""
[[openab_features]]
feature = "streaming"
status = "implemented"
note = "Post-then-edit, not native. use_streaming returns true only when no other bot is present (!other_bot_present); the router consults it at dispatch. uses_native_streaming stays default false, so the trait's native stream methods only hit their edit-based fallbacks."
note = "Post-then-edit, not native. use_streaming remains enabled for single-bot and multibot threads so long-running work stays visible; each bot edits its own presentation message. Presentation mentions are sanitized, while structured handoffs use an explicit trusted_bot_ids target allowlist. uses_native_streaming stays default false, so the trait native stream methods only hit their edit-based fallbacks."
source = ["crates/openab-core/src/discord.rs#use_streaming", "crates/openab-core/src/adapter.rs#uses_native_streaming"]
pr = "#534"

Expand Down Expand Up @@ -215,7 +215,7 @@ pr = ""
[[openab_features]]
feature = "multibot"
status = "implemented"
note = "Early other-bot detection cached (disk-persisted, irreversible); disables streaming, gates via MultibotMentions, enforces bot-turn limits; trusted_bot_ids + @mention admits handoff regardless of allow_bot_messages."
note = "Early other-bot detection is cached (disk-persisted, irreversible); multibot threads keep edit-streaming, gate user replies via MultibotMentions, and enforce bot-turn limits. Structured handoffs use [[handoff:<TARGET_BOT_ID>]] and require the target to be in trusted_bot_ids; presentation mentions are suppressed."
source = ["crates/openab-core/src/discord.rs#MultibotCache", "crates/openab-core/src/discord.rs#BotTurnTracker"]
pr = ""

Expand Down Expand Up @@ -258,7 +258,7 @@ source = "crates/openab-core/src/discord.rs#BotTurnTracker"
date = "2026-07-04"
title = "Multibot detection is irreversible & disk-cached"
note = """
Once any other bot posts in a channel/thread, that thread is permanently "multibot": cached in-memory and persisted to MultibotCache on disk (survives restarts), since bot messages don't disappear. This flips streaming off (the edit-loop interferes across bots) and can require @mention under MultibotMentions.
Once any other bot posts in a channel/thread, that thread is permanently "multibot": cached in-memory and persisted to MultibotCache on disk (survives restarts), since bot messages don't disappear. Multibot mode keeps presentation streaming enabled and can require @mention under MultibotMentions. Ordinary presentation mentions are sanitized; structured handoffs are separately validated and restricted to trusted_bot_ids.
"""
kind = "intrinsic"
source = "crates/openab-core/src/discord.rs#MultibotCache"
Expand All @@ -267,7 +267,7 @@ source = "crates/openab-core/src/discord.rs#MultibotCache"
date = "2026-07-04"
title = "Streaming is post-then-edit, not native"
note = """
Unlike Slack, Discord has no native streaming API; OpenAB streams by editing a placeholder message. use_streaming disables this whenever another bot is present, to avoid edit interference. uses_native_streaming stays false, so the trait's native stream methods only ever hit their edit-based fallbacks.
Unlike Slack, Discord has no native streaming API; OpenAB streams by editing a placeholder message. use_streaming remains enabled when another bot is present, so each bot can keep its own long-running presentation visible. uses_native_streaming stays false, so the trait's native stream methods only ever hit their edit-based fallbacks.
"""
kind = "openab_decision"
source = "crates/openab-core/src/discord.rs#use_streaming"
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,8 @@ async fn main() -> anyhow::Result<()> {
ambient: ambient_dispatcher,
reminder_store: reminder_store.clone(),
scheduled_ids: tokio::sync::Mutex::new(std::collections::HashSet::new()),
handoff_events: tokio::sync::Mutex::new(std::collections::HashMap::new()),
handoff_rates: tokio::sync::Mutex::new(std::collections::HashMap::new()),
};

let intents = GatewayIntents::GUILD_MESSAGES
Expand Down
Loading