Skip to content

feat: opt-in per-channel auto-routing with configurable strategies #2150

Description

@neo-wanderer

Description

Summary

Channel messages (Telegram, WhatsApp, Discord, etc.) are locked to a single agent — the one configured as default_agent. The only way to switch agents is the explicit /agent <name> command.

This proposal adds configurable auto-routing strategies to ChannelOverrides so channels can opt into intelligent, intent-based agent switching while keeping token costs under control.

Problem

The kernel has a full auto-routing pipeline (llm_classify_intent()auto_select_template()auto_select_hand()) that works for CLI and direct API calls. It's explicitly disabled for channels via a guard in resolve_assistant_target() (kernel.rs):

if sender_context.is_some() {
    return Ok(agent_id);
}

Simply removing this guard would re-classify every message ≥15 chars via an LLM call. In a typical 10-message conversation, 9 calls are wasted — the user is still on the same topic. The classifier also only sees the current message (no conversation history), so follow-ups like "now do the same for the other one" would be misclassified.

Proposed Solution

Configuration

[channels.telegram.overrides]
auto_route = "sticky_heuristic"         # off | explicit_only | sticky_ttl | sticky_heuristic
auto_route_ttl_minutes = 30             # Cache expiry for route decisions
auto_route_confidence_threshold = 6     # Heuristic score to trigger re-evaluation
auto_route_sticky_bonus = 4             # Bonus points for current agent (prevents flip-flopping)
auto_route_divergence_count = 2         # Consecutive divergent messages before switching

Strategies

off (default) — Current behavior

Channel messages skip auto-routing. Users switch via /agent <name>. Zero cost.

explicit_only — Sticky with first-message routing

Route on first message via LLM classification, then cache indefinitely (until TTL). Only re-route on explicit /agent <name> or @agent-name inline mention.

  • Cost: 1 LLM call per session
  • Best for: Cost-sensitive channels, public bots

sticky_ttl — Sticky with time-based expiry

Route on first message, cache the result. Re-classify only after auto_route_ttl_minutes of inactivity.

  • Cost: 1 LLM call per TTL window
  • Best for: Long single-topic sessions

sticky_heuristic — Sticky with heuristic-gated re-routing (recommended)

Route on first message, cache the result. On subsequent messages, run the existing cheap heuristic (route_assistant_by_metadata() — keyword scoring, ~0ms, zero tokens):

  1. Score all agent templates against the message using existing auto_select_template() keyword matching
  2. Add sticky_bonus points to the current cached agent's score (bias toward staying)
  3. If the top-scoring agent matches cached agent → skip, reuse cache
  4. If top-scoring agent differs AND score ≥ confidence_threshold → increment a per-user divergence counter
  5. If divergence counter reaches divergence_count consecutive messages suggesting the same different agent → trigger llm_classify_intent() to confirm, then switch
  6. If the next message matches the current agent again → reset divergence counter
  7. Divergence counter resets on TTL expiry — stale divergence signals don't carry over to new sessions
  • Cost: 0 LLM calls for continuations. 1 LLM call only after N consecutive divergent messages (confirmed topic switch).
  • Best for: Power users, multi-purpose channels

Explicit Triggers

In addition to strategy-based auto-routing, users can always force an immediate switch via:

  • /agent <name> — existing slash command
  • @agent-name inline mention (e.g., @coder fix this function) — triggers re-routing immediately, bypassing the divergence counter

Both reset the divergence counter and update the cached route.

Why the Divergence Counter Matters

Without it, a single ambiguous message causes a false switch:

User (talking to ops-agent): "Can you debug this deployment issue?"
  heuristic scores: debugger=6, ops=6 (tie)
  Without counter: might switch to debugger (wrong!)
  With counter (divergence_count=2): needs 2 consecutive messages
    suggesting debugger before switching. Next message "check the k8s pods"
    scores ops=12, counter resets. No false switch.

Sticky Bonus Example

Current agent: ops (cached)
Message: "Write a blog post about our product launch"

Without sticky_bonus:
  content-writer: 18, ops: 0 -> diverges immediately

With sticky_bonus=4:
  content-writer: 18, ops: 0+4=4 -> still diverges (18 >> 4)
  This is a genuine topic switch, bonus doesn't prevent it

Message: "Can you help me think about the architecture?"
Without sticky_bonus:
  coder: 6, ops: 0 -> diverges (weak signal)
With sticky_bonus=4:
  coder: 6, ops: 0+4=4 -> marginal, counter increments but barely
  Prevents switching on weak/ambiguous signals

Interaction with Existing Features

Two-Layer Architecture

  1. Layer 1 — Channel Router (resolve_or_fallback): User bindings, thread routes, channel defaults determine which agent receives the message
  2. Layer 2 — Kernel Auto-Router (resolve_assistant_target): Only fires when resolved agent is the "assistant" template

Auto-routing only affects Layer 2. User-level bindings always win — if a user is bound to a specific non-assistant agent, auto-routing never fires.

User binding Bound to auto_route Result
Yes ops-agent any ops-agent (not assistant, no auto-routing)
Yes assistant off assistant (current behavior)
Yes assistant sticky_heuristic assistant then auto-routes to specialist
None default assistant sticky_heuristic auto-routes to specialist

Re-routing Behavior

  • Messages 15+ chars: evaluated by strategy
  • Messages under 15 chars (follow-ups like "yes"): reuse cached route
  • Brief acknowledgements ("ok", "thanks"): cache skipped
  • /agent <name> or @agent-name: always overrides, resets cache and divergence counter
  • Divergence counter resets on TTL expiry
  • Cache GC: 30 minutes of inactivity

Intent Classifier Limitation

llm_classify_intent() passes only the current message to the LLM — no conversation history. Follow-up messages without clear keywords (e.g., "now do the same for the other one") cannot be classified correctly. This is why sticky routing with heuristic gating is critical — the classifier works best for clear topic-switch messages, not continuations. Keeping it single-message for cost efficiency.

Future: Agent Self-Routing

As a future add-on, agents could signal "this isn't my domain" in their response metadata, triggering a re-route on the next message. This piggybacks on the existing LLM call (zero additional cost) and provides a secondary routing signal that complements the heuristic gate. Implementation would involve:

  • Adding a domain-awareness instruction to agent system prompts
  • Parsing a routing signal from response metadata
  • Using the signal as a divergence hint (equivalent to one divergence counter increment)

Out of scope for the initial implementation but should be considered in the design.

Implementation Scope

Files to modify (~6):

File Change
crates/librefang-types/src/config/types.rs AutoRouteStrategy enum, config fields on ChannelOverrides
crates/librefang-channels/src/types.rs Routing fields on SenderContext
crates/librefang-channels/src/bridge.rs Pass overrides through build_sender_context(), @agent-name mention parsing
crates/librefang-kernel/src/kernel.rs Strategy-aware guard in resolve_assistant_target(), route_assistant_by_metadata_scored() helper, divergence counter on route cache
crates/librefang-kernel/src/router.rs Expose scoring for sticky bonus calculation
docs/channel-auto-routing.md Documentation

Backward compatible: auto_route defaults to off.

Open Questions

  1. Is the default divergence_count of 2 the right balance? Should it be 3 for noisier channels?
  2. Should @agent-name mentions be case-insensitive and support partial matches (e.g., @ops matching ops-agent)?
  3. For the future agent self-routing, should the agent's "not my domain" signal count as 1 divergence increment or immediately trigger LLM confirmation?

Alternatives Considered

No response

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/channelsMessaging channel adaptersenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions