Skip to content

RFC: rethink channel inbound routing — HITL vs AITL topology, channel-instance binding, per-conversation /agent override #5671

Description

@DaBlitzStein

This is a design RFC, not an implementation PR. Filing it to open the debate before any code lands. The full spec lives below; a short version is at the top. Feedback welcome especially on whether the architectural premise is correct before I send atomic PRs.

TL;DR

The current resolve_or_fallback() inbound dispatch chain (crates/librefang-channels/src/bridge.rs:2321-2398) applies the same priority sequence to every inbound message regardless of topology (DM vs group vs anything else). Combined with ThreadOwnershipRegistry's 5-minute TTL and AgentManifest.channels = [] accept-all semantics (#5294 layers b/c), this causes production-visible bugs where a DM rotates between unrelated agents whenever the human goes quiet for longer than the TTL.

Proposed shift:

  • One channel instance hosts exactly one agent. [[sidecar_channels]] already supports the instance concept via name; we make agent mandatory on it.
  • Two explicit topologies: HITL (Human-In-The-Loop, DM-style) and AITL (Agent-In-The-Loop, group/channel space with other participants). Engagement semantics differ between the two; the agent-selection question never arises because each instance is 1:1.
  • Slash command /agent <name> operates at conversation scope, not instance scope. Per-conversation override persisted in DB; instance default seeded from config. Two-level lookup at dispatch.
  • Multi-agent coordination stays inside LibreFang (via agent_send); it does not happen "across" a single external channel.
  • The fallback chain (bindings → direct_routes → user_defaults → channel_defaults → \"assistant\" → list_agents().first()) is deleted from the inbound path. Dispatch becomes a 2-level table lookup.

Why the current code rotates agents on a DM

Reproduced in production journal:

```
turn 1 @ 11:58:44 agent.name=researcher session.id=7b992bc5...
turn 2 @ 16:35:44 agent.name=legal-assistant session.id=7b6d3bac...
```

Same Telegram bot, same human peer, single DM conversation. The peer sent two consecutive messages with ~4.5 hours between them. The bot ended up running each turn on a completely different agent, with different sessions (no shared history). The second agent introduced itself as a new identity ("¡Hola! Soy legal-assistant…") because it genuinely had no record of the prior conversation.

Sequence of decisions inside the daemon:

  1. Turn 1: resolve_or_fallback enters the chain. No binding, no direct route, no user default, no channel default (the sidecar block had no default_agent), "assistant" agent not found by name. Falls to list_agents().first() (bridge.rs:2383-2398) — non-deterministic by HashMap iteration order. Picks researcher. Claim registered in ThreadOwnershipRegistry.
  2. ~4.5 hours pass. Claim TTL (5 min, thread_ownership.rs) long expired.
  3. Turn 2: same chain executes from scratch. Picks legal-assistant this time. Different agent → SessionId::for_channel(agent_id, ...) derives a different session id → no shared history.

Architectural premise that fails: the chain is topology-blind. A DM by definition has one participant talking to one agent — there should be nothing for the resolver to decide. The same logic running in a multi-agent shared space might make some sense; running in a DM produces this rotation bug by design.

Two upstream behaviours compound the problem:

  • channels = [] means accept-all (every agent without an explicit channels allowlist is eligible for every channel). This makes the list_agents().first() pool the entire agent registry.
  • ThreadOwnershipRegistry TTL = 5 min. Sub-human. A user opening Telegram once in the morning and once after lunch hits this.

Proposed model

Topology

Two explicit external-channel topologies, named explicitly so they can be discussed:

  • HITL — Human-In-The-Loop. chat_type=private (DM). One human peer ↔ one bound agent. No other participants. The bound agent always engages. Anything the peer writes is for it. No resolver, no chain, no fallback.
  • AITL — Agent-In-The-Loop. chat_type=group/supergroup/channel (or platform equivalent). The agent is a participant inside a shared space, alongside humans (and possibly other agents hosted by their own separate channel instances). The bot stays silent by default. Engages only under explicit trigger: @mention, own alias / wake phrase, reply to its prior message, slash command, or active sticky claim with the same peer.

Channel-instance binding (1:1)

A [[sidecar_channels]] block represents one external channel instance (one Telegram bot token, one Slack workspace, one Matrix account…). Each instance is bound to exactly one agent. If you want N agents in the same Telegram group, you create N bots (N instances), each bound to its own agent.

  • SidecarChannelConfig.agent: String — mandatory. Currently default_agent: Option<String> (which connotes "fallback"); proposal is to rename and require it, with a serde alias for transitional compatibility.
  • Persistent table channel_instance_defaults(instance_name PRIMARY KEY, agent_id, bound_at, bound_by) seeded from config at boot.

Conversation-level override

Each (instance, conversation_id) pair can carry an override that supersedes the instance default. conversation_id is the peer_id for HITL and the chat_id for AITL.

  • Persistent table conversation_bindings(instance_name, conversation_id, agent_id, bound_at, bound_by). Not seeded from config — populated only by /agent or explicit operator action.

Dispatch path (the deletion)

```
bridge::dispatch_inbound(msg, instance_name):
conv_id = if msg.is_group { msg.chat_id } else { msg.sender.peer_id }
agent = conversation_bindings[(instance, conv_id)]
?? channel_instance_defaults[instance]
?? ERROR

if !msg.is_group {
    engage(agent, msg)            // HITL: always
} else {
    if should_engage_in_group(msg, agent) {
        engage(agent, msg)        // AITL: only on trigger
    } // else silence
}

```

What gets deleted from the inbound path: resolve_or_fallback body, the entire AgentRouter chain (resolve_with_context, direct_routes, user_defaults, channel_defaults, system_default) insofar as it exists to seed dispatch (the RBAC part survives), route_assistant_by_metadata invocations from inbound, ThreadOwnershipRegistry::decide for inbound, AgentManifest.channels as a dispatch filter, the \"assistant\" lookup-by-name, and the list_agents().first() fallback.

Engagement (AITL only)

should_engage_in_group(msg, agent_id) -> bool. Returns true on any of:

  • was_mentioned (bot @-mentioned),
  • text matches one of the bound agent's aliases (metadata.routing.aliases ∪ strong_aliases ∪ weak_aliases) — this also fixes the limitation in feat(channels): trigger agent on routing aliases in group messages (#2292) #2619 where only the channel default's aliases were loaded into group_trigger_patterns,
  • reply-to is a prior message from this bot,
  • slash command,
  • active sticky claim for (instance, conv_id, peer_id).

Returns false on is_addressed_to_other_participant match (PR #2480 preserved) or nothing else fires. Default is silence.

Sticky claim (replaces ThreadOwnershipRegistry for inbound)

PeerStickyRegistry keyed by (instance_name, conversation_id, peer_id), TTL 30 min default (configurable), renewed only when the agent actually responded. Cleared on /agent rebind for that conversation. In-memory (Arc<DashMap>); daemon restarts simply require the peer to re-trigger.

/agent <name> (the operator-facing piece)

  • Scope: the conversation, not the instance.
  • Validation: agent must exist + be enabled. Permission = sender must be admin (DM peer is implicit admin of their own DM; group sender must be a chat admin via getChatAdministrators / equivalent).
  • Action: write conversation_bindings row, clear sticky for this conversation, publish a handoff line via the outgoing agent ("Switched to @. Conversation context handed over."), inject a summary into the incoming agent's session, audit-log.
  • /agent reset clears the override; falls back to instance default.
  • REST mirror: PUT/DELETE /api/channels/<instance>/conversations/<conv_id>/agent + dashboard UI.

Session continuity across /agent swap

Each agent keeps its SessionId::for_channel(agent, \"<instance>:<chat_id>\") (existing scheme). After swap, the new agent gets a fresh session whose first system message is a summary of the outgoing session (auto-compact if available; else last N turns; cap 2000 tokens). No raw-history leak between agents.

What this means for existing code

  • The instance concept (SidecarChannelConfig.name) already exists upstream — no need to invent it.
  • AgentBinding already supports match-rules with channel / account_id / peer_id / guild_id / roles. The proposed conversation_bindings table could collapse into an extension of AgentBinding with priority flags rather than two separate tables; this is an implementation decision.
  • [[bindings]] config block + POST /api/bindings runtime mutation already exist. The /agent slash handler likely wraps the same underlying storage.
  • should_process_group_message and the entire engagement-trigger system are kept — they just stop pretending to be channel-level and become per-(bound-agent) since each channel-instance has only one agent to evaluate.
  • ThreadOwnershipRegistry may stay if internal multi-agent code uses it.

Why I'm filing this as an RFC instead of a PR

The change touches the entire inbound path. Going straight to atomic PRs (which is the right end state — I'm aware of the prior feedback on bundled diffs and will respect that) without confirming the model first risks re-doing work. If the design is approved here, I'll split into the PRs listed in the rollout below.

Atomic PR rollout (if approved)

  1. feat(types,channels): rename SidecarChannelConfig.default_agent → agent + deprecation alias
  2. feat(memory): channel_instance_defaults + conversation_bindings tables + repo
  3. feat(channels): seed channel_instance_defaults from config on boot
  4. feat(channels): dispatch_inbound replaces resolve_or_fallback (the structural one)
  5. feat(runtime): /agent slash command writes to conversation_bindings
  6. feat(api): channel default-agent + conversation-override endpoints
  7. feat(dashboard): default-agent + overrides UI on ChannelsPage

Open questions

  • Permission model for /agent when no platform admin check is exposed by the adapter — accept only in HITL, reject in AITL? Or require explicit per-instance RBAC config?
  • Default sticky_ttl_minutes: 30 proposed; willing to discuss.
  • Handoff summary format when no auto_compact summary is available — last N turns? Operator-defined template?
  • Should conversation_bindings collapse into an extension of AgentBinding with priority flags, or stay as a separate table?

Asks

  • Confirm the topology framing (HITL vs AITL) is the right level of abstraction.
  • Confirm the deletion of the resolver chain from the inbound path is the desired end state (vs an additive sticky layer that keeps the chain underneath).
  • Confirm the proposed /agent scope (conversation, not instance) matches operator expectations.
  • Push back on anything that misreads the upstream code — I have ground-truth citations for each claim above but would rather get the design wrong here on paper than in code.

Related: #5323 (the per-conversation routing issue this RFC supersedes), #5294 layers b/c (the fallback-chain non-determinism + accept-all channels=[] semantics this RFC eliminates by construction).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/channelsMessaging channel adaptersauto-close-candidateBot thinks this is resolved; awaiting confirmationawaiting-responseMaintainer replied — waiting for reporter/contributor feedback

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions