Skip to content

bug(channels): channel-side /commands ignore account_id — multi-bot deployments route commands to wrong agent (and /agent leaks user-defaults across bots) #5672

Description

@nevgenov

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):

  1. Send /agent agent-C to bot-a → reply: Now talking to agent: agent-C
  2. Without using /agent anywhere else, send a normal text message to bot-b.
  3. 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.
  4. daemon.log confirms: agent.name=agent-C handled the message that arrived on bot-b, not agent-B.
  5. 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-3841if !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_botsset_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/apiREST/WS endpoints and dashboardarea/channelsMessaging channel adaptersarea/ciCI/CD and build toolinghas-prA pull request has been linked to this issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions