feat(channels,kernel): per-conversation agent routing for multi-agent groups (#5323)#6127
Conversation
… groups (#5323) Route a group message that names a specific non-default agent to that agent instead of the channel default, and keep the conversation sticky to it. Two addressing paths feed a new agent resolution step ahead of the binding/default chain: an explicit @-mention the adapter surfaces in metadata["mention_names"] (resolved against agent names/handles), and a non-default agent's channel_overrides.group_trigger_patterns alias matching the text. Alias disambiguation uses a deterministic per-agent attention scorer (librefang_channels::bridge::best_alias_match) reusing the compiled regex cache, which the dispatch path also consults before the previously non-deterministic "first available" fallback (closes layer (c) of #5294). ThreadKey grows three optional slices — account_id (multi-tenant, the unlanded #3419/#3420 fix), chat_id, and peer_id (per-sender stickiness) — all defaulting to None so the historical (channel, thread) key is reproduced byte-for-byte. A topic-less group now claims by chat id instead of bypassing the registry, and a live claim makes a follow-up sticky to the same agent without a fresh mention; an explicit address re-claims for the new agent, preserving #3334 TTL semantics. New per-channel knobs conversation_ownership_ttl_seconds (default 600) and conversation_ownership_include_dms (default false). The Telegram / Discord / Slack / Matrix sidecar adapters surface mention_names and a per-group sender_user_id so the bridge can route and scope per peer. Built on #5671 PR-A's agent/available_agents schema; additive and backward compatible. Verified: cargo check --workspace --lib, clippy -D warnings, cargo fmt --check, cargo test -p librefang-channels (493) and -p librefang-types config, and the Python sidecar suites (288).
Doc comments added in the multi-agent routing change were mid-sentence wrapped at ~80 columns. The prose convention (CLAUDE.md) requires no column limit — break only at sentence boundaries so a single-word edit doesn't reflow an entire paragraph. Affected: ThreadKey struct/builder docs, best_alias_match, RouteResolution, mention_names, agent_allows_channel, resolve_addressed_agent, build_thread_key, conversation_ownership_allows, module-level doc. No logic change; rustfmt passes.
houko
left a comment
There was a problem hiding this comment.
Redundant scorer invocation in resolve_or_fallback (step 4) for group messages
In crates/librefang-channels/src/bridge.rs, resolve_or_fallback calls route_assistant_by_metadata_for_channel twice on the same message text when message.is_group is true and the routing chain returns None:
- Step 2 — inside
resolve_addressed_agent, which is only called for groups. If no explicit@-mention resolves, it callsroute_assistant_by_metadata_for_channel(ct, text). - Step 4 — unconditionally after the routing chain, calling
route_assistant_by_metadata_for_channel(ct, text)again.
Both calls use identical arguments (ct, text) against the same candidate set (channel-eligible agents whose manifest.channels allows the channel). best_alias_match is deterministic and pure, so the second result is always identical to the first. The step-4 invocation is unreachable-in-effect for group messages.
For non-group messages (DMs) step 2 is skipped, so step 4 does run — but the routing chain almost always resolves a DM to the bound agent before reaching step 4.
Options:
- Guard step 4 with
if !message.is_group { … }to skip the redundant call for groups. - Remove step 4 entirely and document that groups rely on step 2; DMs rely on the binding chain which should always resolve before reaching the "first available" tail.
- Leave as-is if this is intentional defence-in-depth (the extra call is cheap since the regex cache is warm).
Just flagging — the current behaviour is correct, this is purely an efficiency / clarity question. Happy to defer to your judgement on which option fits the intended design.
Generated by Claude Code
…channel-ineligible holder (#5323) (#6132) * fix(channels): take over a stale conversation-ownership claim from a channel-ineligible holder (#5323) Follow-up to #6127. When agent A holds a conversation-ownership claim and A's manifest.channels allowlist is later narrowed to exclude that channel, the still-live claim suppressed every non-addressed follow-up — routed to an eligible agent B — until the TTL (default 600s) expired, a silent message drop. conversation_ownership_allows now reads the current holder and, when the holder can no longer serve the channel (agent_allows_channel is false), treats the dispatch as a takeover so the eligible candidate re-claims immediately. A still-eligible holder keeps its claim unchanged; a killed holder continues to degrade to a graceful send_message error (its empty allowlist reads as "all channels", so it is not treated as stale). Verified: cargo test -p librefang-channels --lib (494, incl. new stale_holder_loses_claim_when_channel_ineligible with an eligible-holder control), cargo clippy -p librefang-channels --lib -- -D warnings, cargo fmt --check — all clean. * fix(changelog): restore deleted memory bullet and trim multi-line comment blocks The channels stale-claim bullet's last continuation line had the entire `**memory: Matrix peers…**` entry appended to it (the deleted bullet was never re-added as its own entry), so Closes #6100 was attributed to the wrong section. Restored the memory bullet as a separate entry. Also condensed three multi-line comment blocks in bridge.rs to single lines per CLAUDE.md style rules. --------- Co-authored-by: Evan <[email protected]> Co-authored-by: Claude <[email protected]>
Summary
Closes the multi-agent-group routing gap in #5323: when more than one agent serves a channel, a group message that names a specific non-default agent now reaches that agent instead of always falling to the channel default, and the conversation stays sticky to it.
Built on top of #5671 PR-A (
SidecarChannelConfig.agent+available_agents, merged in #6105), which adopted Model A. This is the AITL routing layer that PR-A's CHANGELOG entry anticipated ("resolve_or_fallback, dispatch … arrive in later PRs"). All five steps from the issue land in one PR.What changed (per issue step)
Step 1 —
ThreadKeyreshape (thread_ownership.rs). The conversation-ownership claim key grows three optional slices, all defaulting toNoneso the historical(channel, thread)key is reproduced byte-for-byte:account_id— two bot accounts on one channel-type no longer collide (the unlanded fix(channels): multi-tenant ThreadKey + sweep_expired wiring (#3414 follow-up) #3419/fix(channels): account_id in ThreadKey + lazy sweep + push bypass note #3420 fix).chat_id— two chats reusing a forum-topic id stay distinct.peer_id— per-sender stickiness, so two users in one thread can talk to two different agents without contaminating each other.decide_with_ttllets the bridge pass a per-channel TTL while every channel shares one registry.Step 2 + 4 — multi-agent alias/mention routing (
bridge.rs,channel_bridge.rs). A new resolution step runs ahead of the binding/default chain for group messages:@-mention surfaced by the adapter inmetadata["mention_names"], resolved against agent names/handles (channel-eligibility enforced via the existingmanifest.channelsallowlist).channel_overrides.group_trigger_patternsalias matching the message text, scored by a new deterministic per-agent attention scorerlibrefang_channels::bridge::best_alias_match(reuses the compiled-regex cache; ties →None).The dispatch path also consults the scorer before the previously non-deterministic "first available" fallback, closing layer (c) of #5294. The kernel-side
route_assistant_by_metadata_for_channelenumerates channel-eligible agents and delegates to the cached scorer (no newregexdependency added tolibrefang-api).Step 1b — claim-aware dispatch +
include_dms. A topic-less group now claims bychat_idinstead of bypassing the registry (gap #5). A live claim makes a follow-up sticky to the same agent without a fresh mention; an explicit address re-claims for the new agent, preserving #3334's refresh/re-claim TTL semantics. The two duplicated gates (text + multimodal paths) are unified into oneconversation_ownership_allowshelper.Step 5 — config knobs (
ChannelOverrides).conversation_ownership_ttl_seconds(default600) andconversation_ownership_include_dms(defaultfalse).Step 3 — sidecar mention surfacing (Python). Telegram (UTF-16-correct entity parsing), Discord, Slack, and Matrix now attach
mention_namesand a per-groupsender_user_idso the bridge can route to a named agent and scope claims per peer. Telegram previously set no mention metadata at all.Design notes
metadata.routing.handlesJSON convention; aliases were already refactored to typedchannel_overrides.group_trigger_patterns, so this PR routes against agent names + those typed aliases rather than reintroducing the untyped bag. The "ambrogio, ayúdame" example from the issue works purely through the alias scorer on message text — no platform@-mention needed.manifest.channelsallowlist (the mechanismresolve_or_fallbackalready enforced). The channel-sideavailable_agentswhitelist from RFC: rethink channel inbound routing — HITL vs AITL topology, channel-instance binding, per-conversation /agent override #5671 remains schema-only ("not yet consulted") and its enforcement is the forthcoming/agentPR — out of scope here.Verification
cargo check --workspace --lib— clean.cargo clippy -p librefang-channels -p librefang-api -p librefang-types --lib -- -D warnings— clean.cargo fmt --check— clean.cargo test -p librefang-channels --lib— 493 passed, incl. newbest_alias_match_*,build_thread_key_*,resolve_addressed_agent_routes_explicit_mention,same_thread_distinct_account_id_does_not_collide,same_thread_distinct_peer_does_not_collide,per_channel_ttl_overrides_default.cargo test -p librefang-types --lib config::— 157 passed, incl.test_conversation_ownership_knobs_roundtrip.test_inbound_mentions_and_sender_user_id(covers the UTF-16 offset case).Live LLM verification is not required — no LLM call path changed.
Test-plan coverage (from the issue)
@agentBin multi-agent group routes to Bresolve_addressed_agent_routes_explicit_mention,best_alias_match_picks_unique_winnerresolve_or_fallbacksame_thread_distinct_peer_does_not_collide+ per-peerThreadKeyper_channel_ttl_overrides_defaultbest_alias_match_picks_unique_winnerbuild_thread_key_falls_back_to_chat_id_without_topicsame_thread_distinct_account_id_does_not_collide