Skip to content

feat(channels,kernel): per-conversation agent routing for multi-agent groups (#5323)#6127

Merged
houko merged 2 commits into
mainfrom
feat/per-conversation-routing
Jun 16, 2026
Merged

feat(channels,kernel): per-conversation agent routing for multi-agent groups (#5323)#6127
houko merged 2 commits into
mainfrom
feat/per-conversation-routing

Conversation

@houko

@houko houko commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

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 — ThreadKey reshape (thread_ownership.rs). The conversation-ownership claim key grows three optional slices, all defaulting to None so the historical (channel, thread) key is reproduced byte-for-byte:

decide_with_ttl lets 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:

  • Explicit @-mention surfaced by the adapter in metadata["mention_names"], resolved against agent names/handles (channel-eligibility enforced via the existing manifest.channels allowlist).
  • A non-default agent's channel_overrides.group_trigger_patterns alias matching the message text, scored by a new deterministic per-agent attention scorer librefang_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_channel enumerates channel-eligible agents and delegates to the cached scorer (no new regex dependency added to librefang-api).

Step 1b — claim-aware dispatch + include_dms. A topic-less group now claims by chat_id instead 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 one conversation_ownership_allows helper.

Step 5 — config knobs (ChannelOverrides). conversation_ownership_ttl_seconds (default 600) and conversation_ownership_include_dms (default false).

Step 3 — sidecar mention surfacing (Python). Telegram (UTF-16-correct entity parsing), Discord, Slack, and Matrix now attach mention_names and a per-group sender_user_id so the bridge can route to a named agent and scope claims per peer. Telegram previously set no mention metadata at all.

Design notes

  • The issue proposed a metadata.routing.handles JSON convention; aliases were already refactored to typed channel_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.
  • Routing respects the per-agent manifest.channels allowlist (the mechanism resolve_or_fallback already enforced). The channel-side available_agents whitelist 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 /agent PR — 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. new best_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.
  • Python sidecar suites (telegram/discord/slack/matrix) — 288 passed, incl. new 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)

Scenario Covered by
@agentB in multi-agent group routes to B resolve_addressed_agent_routes_explicit_mention, best_alias_match_picks_unique_winner
Follow-up from same peer (no mention) stays with B sticky-holder branch in resolve_or_fallback
Another peer in same thread not contaminated same_thread_distinct_peer_does_not_collide + per-peer ThreadKey
TTL expires → claim drops → re-evaluate per_channel_ttl_overrides_default
Alias declared by non-default agentB routes to B best_alias_match_picks_unique_winner
Topic-less group respects claim via chat_id build_thread_key_falls_back_to_chat_id_without_topic
Same thread_id under different account_id no collision same_thread_distinct_account_id_does_not_collide

… 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).
@github-actions github-actions Bot added area/docs Documentation and guides area/channels Messaging channel adapters area/sdk JavaScript and Python SDKs size/L 250-999 lines changed labels Jun 16, 2026
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 houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 calls route_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:

  1. Guard step 4 with if !message.is_group { … } to skip the redundant call for groups.
  2. 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.
  3. 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

@houko
houko merged commit 80b7e92 into main Jun 16, 2026
32 checks passed
@houko
houko deleted the feat/per-conversation-routing branch June 16, 2026 06:59
houko added a commit that referenced this pull request Jun 16, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/docs Documentation and guides area/sdk JavaScript and Python SDKs size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants