You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
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.
~4.5 hours pass. Claim TTL (5 min, thread_ownership.rs) long expired.
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.
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:
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.
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)
feat(types,channels): rename SidecarChannelConfig.default_agent → agent + deprecation alias
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).
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 withThreadOwnershipRegistry's 5-minute TTL andAgentManifest.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:
[[sidecar_channels]]already supports the instance concept vianame; we makeagentmandatory on it./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.agent_send); it does not happen "across" a single external channel.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:
resolve_or_fallbackenters the chain. No binding, no direct route, no user default, no channel default (the sidecar block had nodefault_agent), "assistant" agent not found by name. Falls tolist_agents().first()(bridge.rs:2383-2398) — non-deterministic byHashMapiteration order. Picksresearcher. Claim registered inThreadOwnershipRegistry.thread_ownership.rs) long expired.legal-assistantthis 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 explicitchannelsallowlist is eligible for every channel). This makes thelist_agents().first()pool the entire agent registry.ThreadOwnershipRegistryTTL = 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:
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.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. Currentlydefault_agent: Option<String>(which connotes "fallback"); proposal is to rename and require it, with a serde alias for transitional compatibility.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_idis the peer_id for HITL and the chat_id for AITL.conversation_bindings(instance_name, conversation_id, agent_id, bound_at, bound_by). Not seeded from config — populated only by/agentor 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
```
What gets deleted from the inbound path:
resolve_or_fallbackbody, the entireAgentRouterchain (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_metadatainvocations from inbound,ThreadOwnershipRegistry::decidefor inbound,AgentManifest.channelsas a dispatch filter, the\"assistant\"lookup-by-name, and thelist_agents().first()fallback.Engagement (AITL only)
should_engage_in_group(msg, agent_id) -> bool. Returns true on any of:was_mentioned(bot @-mentioned),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 intogroup_trigger_patterns,(instance, conv_id, peer_id).Returns false on
is_addressed_to_other_participantmatch (PR #2480 preserved) or nothing else fires. Default is silence.Sticky claim (replaces ThreadOwnershipRegistry for inbound)
PeerStickyRegistrykeyed by(instance_name, conversation_id, peer_id), TTL 30 min default (configurable), renewed only when the agent actually responded. Cleared on/agentrebind for that conversation. In-memory (Arc<DashMap>); daemon restarts simply require the peer to re-trigger./agent <name>(the operator-facing piece)getChatAdministrators/ equivalent).conversation_bindingsrow, 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 resetclears the override; falls back to instance default.PUT/DELETE /api/channels/<instance>/conversations/<conv_id>/agent+ dashboard UI.Session continuity across
/agentswapEach 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
SidecarChannelConfig.name) already exists upstream — no need to invent it.AgentBindingalready supports match-rules withchannel / account_id / peer_id / guild_id / roles. The proposedconversation_bindingstable could collapse into an extension ofAgentBindingwith priority flags rather than two separate tables; this is an implementation decision.[[bindings]]config block +POST /api/bindingsruntime mutation already exist. The/agentslash handler likely wraps the same underlying storage.should_process_group_messageand 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.ThreadOwnershipRegistrymay 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)
feat(types,channels): rename SidecarChannelConfig.default_agent → agent + deprecation aliasfeat(memory): channel_instance_defaults + conversation_bindings tables + repofeat(channels): seed channel_instance_defaults from config on bootfeat(channels): dispatch_inbound replaces resolve_or_fallback(the structural one)feat(runtime): /agent slash command writes to conversation_bindingsfeat(api): channel default-agent + conversation-override endpointsfeat(dashboard): default-agent + overrides UI on ChannelsPageOpen questions
/agentwhen no platform admin check is exposed by the adapter — accept only in HITL, reject in AITL? Or require explicit per-instance RBAC config?sticky_ttl_minutes: 30 proposed; willing to discuss.auto_compactsummary is available — last N turns? Operator-defined template?conversation_bindingscollapse into an extension ofAgentBindingwith priority flags, or stay as a separate table?Asks
/agentscope (conversation, not instance) matches operator expectations.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).