bug(channels): channel-side /commands ignore account_id — multi-bot deployments route commands to the wrong agent (and /agent leaks user-defaults across bots)
Summary
In a multi-bot Telegram (or other multi-instance channel) deployment, every channel-side slash command (/model, /new, /reboot, /compact, /stop, /usage, /think, /btw) is dispatched to the first-registered channel agent regardless of which bot received the message. Separately, /agent writes a per-peer_id override (user_defaults) that leaks across every bot the same user has access to.
The regular non-command message dispatcher at bridge.rs:2725 calls resolve_with_context(...) and routes correctly — the regression is isolated to the /command arms.
Repro
Tested e2e on v2026.5.17-beta.12 (commit 953fc93) against three Telegram bots configured with distinct default_agent mappings:
[[channels.telegram]]
account_id = "bot-a" # first in config order
bot_token_env = "BOT_A_TOKEN"
default_agent = "agent-A" # provider = anthropic, model = claude-sonnet-4-6
[[channels.telegram]]
account_id = "bot-b"
bot_token_env = "BOT_B_TOKEN"
default_agent = "agent-B" # provider = deepseek, model = deepseek-v4-flash
[[channels.telegram]]
account_id = "bot-c"
bot_token_env = "BOT_C_TOKEN"
default_agent = "agent-C" # provider = deepseek, model = deepseek-chat
All three agents exist, run, and are correctly bound in daemon.log:
INFO telegram default agent: agent-A (...) [channel: telegram:bot-a]
INFO telegram default agent: agent-B (...) [channel: telegram:bot-b]
INFO telegram default agent: agent-C (...) [channel: telegram:bot-c]
Bug A — /model and other channel commands collapse to system default
After a clean daemon restart (in-memory user_defaults empty, verified via kill -9 + launchd respawn):
| # |
Bot user typed /model in |
Reply |
Expected per default_agent |
| 1 |
bot-b |
Current model: claude-sonnet-4-6 (provider: anthropic) |
deepseek-v4-flash / deepseek |
| 2 |
bot-c |
Current model: claude-sonnet-4-6 (provider: anthropic) |
deepseek-chat / deepseek |
| 3 |
bot-a |
Current model: claude-sonnet-4-6 (provider: anthropic) |
claude-sonnet-4-6 / anthropic (matches agent-A by coincidence) |
All three bots return the same model — that of the first-registered channel default (agent-A), regardless of which bot the user typed /model in.
Regular non-command messages dispatched in the same session route correctly (verified in daemon.log):
agent.name=agent-B ← message to bot-b
agent.name=agent-C ← message to bot-c
So the regression is isolated to the /command arms, not the dispatch path.
Bug B — /agent user-default leaks across bots
Starting from another clean restart (verified: regular message to bot-b is handled by agent-B):
- Send
/agent agent-C to bot-a → reply: Now talking to agent: agent-C
- Without using
/agent anywhere else, send a normal text message to bot-b.
- Observed: the Telegram reply from
bot-b is rendered by agent-C — it invokes agent-C's MCP tools (a domain that has nothing to do with agent-B) and answers in agent-C's persona.
daemon.log confirms: agent.name=agent-C handled the message that arrived on bot-b, not agent-B.
- After a second clean restart, the same message to
bot-b routes back to agent-B — confirming the leak lives in in-memory user_defaults.
Root cause
Two distinct shortcomings in crates/librefang-channels/src/router.rs and how bridge.rs calls into it. Both stem from the same architectural gap: account_id is never plumbed through the command path.
Layer A — channel command handlers use context-less resolve()
router.rs:153 (resolve) does not accept BindingContext and falls back at step 3 to:
// 3. Per-channel-type default
if let Some(agent) = self.channel_defaults.get(&channel_key) { // channel_key = "telegram"
return Some(*agent);
}
It probes the generic key ("telegram") only, never the account-qualified key ("telegram:<account_id>"). The first telegram channel default registered at boot is also set as the system-wide default_agent (channel_bridge.rs:3838-3841 — if !system_default_set { router.set_default(agent_id); ... }), so the lookup falls through step 3 (no match for plain "telegram" because all keys are qualified after #4861) and hits the system default — which is the first-registered bot's agent. It wins for every bot.
By contrast, router.rs:203 (resolve_with_context) does:
if let Some(account_id) = ctx.account_id.as_deref() {
let account_key = format!("{}:{}", channel_key, account_id);
if let Some(agent) = self.channel_defaults.get(&account_key) {
return Some(*agent);
}
}
if let Some(agent) = self.channel_defaults.get(&channel_key) { ... }
— exactly the lookup that the existing fix #4861 enables. The bug is that command handlers don't go through this path.
Every command match arm in bridge.rs calls plain resolve():
| Line |
Command |
| 5392 |
/btw |
| 5421 |
/new (the #4868 fix scoped the session reset but kept the wrong agent resolution) |
| 5443 |
/reboot |
| 5465 |
/compact |
| 5487 |
/model |
| 5511 |
/stop |
| 5525 |
/usage |
| 5539 |
/think |
| 3466 |
(additional non-command resolve) |
Only the message-dispatch path at bridge.rs:2725 builds a BindingContext with account_id and calls resolve_with_context. Commands skip it.
Layer B — user_defaults is keyed by peer_id only
router.rs:35:
user_defaults: DashMap<String, AgentId>, // key = peer_id (no channel)
router.rs:104:
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
self.user_defaults.insert(user_key, agent_id);
}
bridge.rs:5369 (/agent command handler):
router.set_user_default(sender.platform_id.clone(), agent_id);
format!("Now talking to agent: {agent_name}")
platform_id is the platform-side user_id (same value across all bots that share the user). The key has no channel_account_id. Subsequent lookups in resolve() and resolve_with_context() (router.rs:183-190 and router.rs:222-228 respectively) probe user_defaults before any channel-level default, so a /agent in one bot silently overrides every other bot's default_agent for that user.
This is the same architectural class as the /new scope leak fixed in #4868 / #4905, just applied to a different DashMap.
Why this didn't manifest earlier
In single-bot or single-default-agent deployments the bug is masked:
- Layer A: when only one Telegram channel default exists, the first-registered is the only one;
resolve() returns the right agent by accident.
- Layer B: when all bots share the same
default_agent, the user_defaults override coincides with the channel default and is invisible.
The bug surfaces only after splitting bots onto distinct default_agents.
Proposed fix
Layer A
Plumb a BindingContext (with account_id) into each command match arm and call resolve_with_context instead of resolve. The required data is already available at the call site — sender carries the channel adapter's account_id (it's used by bridge.rs:2725 already). A small helper:
fn build_command_context<'a>(channel_type: &ChannelType, sender: &'a SenderContext) -> BindingContext<'a> {
BindingContext {
channel: Cow::Borrowed(channel_type_to_str(channel_type)),
account_id: sender.account_id.as_deref().map(Cow::Borrowed),
peer_id: Cow::Borrowed(&sender.platform_id),
guild_id: sender.guild_id.as_deref().map(Cow::Borrowed),
roles: sender.roles.clone().into(),
}
}
…and swap every router.resolve(...) in the command arms for router.resolve_with_context(channel_type, &sender.platform_id, sender.librefang_user.as_deref(), &ctx).
Layer B
Change user_defaults to a composite key:
user_defaults: DashMap<(String, String), AgentId>, // (channel_account_key, user_key)
…where channel_account_key mirrors channel_defaults' format ("telegram:<account_id>"). Update set_user_default:
pub fn set_user_default(&self, channel_account_key: String, user_key: String, agent_id: AgentId) {
self.user_defaults.insert((channel_account_key, user_key), agent_id);
}
Update the /agent handler at bridge.rs:5369/5376 to pass the qualified key, and update both resolvers' lookup at router.rs:183-190 and router.rs:222-228 to probe (channel_account_key, user_key).
No migration needed — user_defaults is in-memory DashMap (verified: nothing in kv_store, sessions, or on-disk JSON references it).
Tests to add
test_command_resolution_respects_account_id — register two telegram channel defaults under different account_ids, send /model from each, assert each returns its own bot's default.
test_user_default_does_not_leak_across_bots — set_user_default(("telegram:bot-a", "user-1"), agent_a), then resolve_with_context(channel=telegram, account_id=bot-b, user=user-1) returns bot-b's channel default, not agent_a.
Environment
- LibreFang
v2026.5.17-beta.12 (commit 953fc93), aarch64-darwin (Apple Silicon)
- Multi-bot Telegram setup (5 bots, multiple agents, Anthropic + DeepSeek providers)
- Reproduced consistently across daemon restarts (Layer A) and within a single session lifetime (Layer B, cleared by daemon restart since
user_defaults is in-memory)
Related
bug(channels): channel-side /commands ignore
account_id— multi-bot deployments route commands to the wrong agent (and /agent leaks user-defaults across bots)Summary
In a multi-bot Telegram (or other multi-instance channel) deployment, every channel-side slash command (
/model,/new,/reboot,/compact,/stop,/usage,/think,/btw) is dispatched to the first-registered channel agent regardless of which bot received the message. Separately,/agentwrites a per-peer_idoverride (user_defaults) that leaks across every bot the same user has access to.The regular non-command message dispatcher at
bridge.rs:2725callsresolve_with_context(...)and routes correctly — the regression is isolated to the/commandarms.Repro
Tested e2e on
v2026.5.17-beta.12(commit953fc93) against three Telegram bots configured with distinctdefault_agentmappings:All three agents exist, run, and are correctly bound in
daemon.log:Bug A —
/modeland other channel commands collapse to system defaultAfter a clean daemon restart (in-memory
user_defaultsempty, verified viakill -9+ launchd respawn):/modelindefault_agentbot-bCurrent model: claude-sonnet-4-6 (provider: anthropic)deepseek-v4-flash / deepseekbot-cCurrent model: claude-sonnet-4-6 (provider: anthropic)deepseek-chat / deepseekbot-aCurrent model: claude-sonnet-4-6 (provider: anthropic)claude-sonnet-4-6 / anthropic(matchesagent-Aby coincidence)All three bots return the same model — that of the first-registered channel default (
agent-A), regardless of which bot the user typed/modelin.Regular non-command messages dispatched in the same session route correctly (verified in
daemon.log):So the regression is isolated to the
/commandarms, not the dispatch path.Bug B —
/agentuser-default leaks across botsStarting from another clean restart (verified: regular message to
bot-bis handled byagent-B):/agent agent-Ctobot-a→ reply:Now talking to agent: agent-C/agentanywhere else, send a normal text message tobot-b.bot-bis rendered byagent-C— it invokesagent-C's MCP tools (a domain that has nothing to do withagent-B) and answers inagent-C's persona.daemon.logconfirms:agent.name=agent-Chandled the message that arrived onbot-b, notagent-B.bot-broutes back toagent-B— confirming the leak lives in in-memoryuser_defaults.Root cause
Two distinct shortcomings in
crates/librefang-channels/src/router.rsand howbridge.rscalls into it. Both stem from the same architectural gap:account_idis never plumbed through the command path.Layer A — channel command handlers use context-less
resolve()router.rs:153(resolve) does not acceptBindingContextand falls back at step 3 to:It probes the generic key (
"telegram") only, never the account-qualified key ("telegram:<account_id>"). The first telegram channel default registered at boot is also set as the system-widedefault_agent(channel_bridge.rs:3838-3841—if !system_default_set { router.set_default(agent_id); ... }), so the lookup falls through step 3 (no match for plain"telegram"because all keys are qualified after #4861) and hits the system default — which is the first-registered bot's agent. It wins for every bot.By contrast,
router.rs:203(resolve_with_context) does:— exactly the lookup that the existing fix #4861 enables. The bug is that command handlers don't go through this path.
Every command match arm in
bridge.rscalls plainresolve():/btw/new(the #4868 fix scoped the session reset but kept the wrong agent resolution)/reboot/compact/model/stop/usage/thinkOnly the message-dispatch path at
bridge.rs:2725builds aBindingContextwithaccount_idand callsresolve_with_context. Commands skip it.Layer B —
user_defaultsis keyed bypeer_idonlyrouter.rs:35:router.rs:104:bridge.rs:5369(/agentcommand handler):platform_idis the platform-side user_id (same value across all bots that share the user). The key has nochannel_account_id. Subsequent lookups inresolve()andresolve_with_context()(router.rs:183-190androuter.rs:222-228respectively) probeuser_defaultsbefore any channel-level default, so a/agentin one bot silently overrides every other bot'sdefault_agentfor that user.This is the same architectural class as the
/newscope leak fixed in #4868 / #4905, just applied to a different DashMap.Why this didn't manifest earlier
In single-bot or single-default-agent deployments the bug is masked:
resolve()returns the right agent by accident.default_agent, theuser_defaultsoverride coincides with the channel default and is invisible.The bug surfaces only after splitting bots onto distinct
default_agents.Proposed fix
Layer A
Plumb a
BindingContext(withaccount_id) into each command match arm and callresolve_with_contextinstead ofresolve. The required data is already available at the call site —sendercarries the channel adapter'saccount_id(it's used bybridge.rs:2725already). A small helper:…and swap every
router.resolve(...)in the command arms forrouter.resolve_with_context(channel_type, &sender.platform_id, sender.librefang_user.as_deref(), &ctx).Layer B
Change
user_defaultsto a composite key:…where
channel_account_keymirrorschannel_defaults' format ("telegram:<account_id>"). Updateset_user_default:Update the
/agenthandler atbridge.rs:5369/5376to pass the qualified key, and update both resolvers' lookup atrouter.rs:183-190androuter.rs:222-228to probe(channel_account_key, user_key).No migration needed —
user_defaultsis in-memory DashMap (verified: nothing inkv_store,sessions, or on-disk JSON references it).Tests to add
test_command_resolution_respects_account_id— register two telegram channel defaults under differentaccount_ids, send/modelfrom each, assert each returns its own bot's default.test_user_default_does_not_leak_across_bots—set_user_default(("telegram:bot-a", "user-1"), agent_a), thenresolve_with_context(channel=telegram, account_id=bot-b, user=user-1)returns bot-b's channel default, notagent_a.Environment
v2026.5.17-beta.12(commit953fc93), aarch64-darwin (Apple Silicon)user_defaultsis in-memory)Related
channel_defaultslookup that resolves the data, but no command-side caller actually uses it./newwipes ALL agent sessions, not just the channel-derived one #4868 / fix(channels,kernel): scope/newto the calling channel + purge JSONL on delete (#4868) #4905 — scoped/newsession reset to the calling channel; missed the agent resolution itself (samerouter.resolve(...)call atbridge.rs:5421is still context-less).resolve_with_context); the command path was not migrated alongside.ThreadKeyextension); orthogonal to this report but shares the "missingaccount_idin routing keys" theme.