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):
- Score all agent templates against the message using existing
auto_select_template() keyword matching
- Add
sticky_bonus points to the current cached agent's score (bias toward staying)
- If the top-scoring agent matches cached agent → skip, reuse cache
- If top-scoring agent differs AND score ≥
confidence_threshold → increment a per-user divergence counter
- If divergence counter reaches
divergence_count consecutive messages suggesting the same different agent → trigger llm_classify_intent() to confirm, then switch
- If the next message matches the current agent again → reset divergence counter
- 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
- Layer 1 — Channel Router (
resolve_or_fallback): User bindings, thread routes, channel defaults determine which agent receives the message
- 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
- Is the default
divergence_count of 2 the right balance? Should it be 3 for noisier channels?
- Should
@agent-name mentions be case-insensitive and support partial matches (e.g., @ops matching ops-agent)?
- 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
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
ChannelOverridesso 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 inresolve_assistant_target()(kernel.rs):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
Strategies
off(default) — Current behaviorChannel messages skip auto-routing. Users switch via
/agent <name>. Zero cost.explicit_only— Sticky with first-message routingRoute on first message via LLM classification, then cache indefinitely (until TTL). Only re-route on explicit
/agent <name>or@agent-nameinline mention.sticky_ttl— Sticky with time-based expiryRoute on first message, cache the result. Re-classify only after
auto_route_ttl_minutesof inactivity.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):auto_select_template()keyword matchingsticky_bonuspoints to the current cached agent's score (bias toward staying)confidence_threshold→ increment a per-user divergence counterdivergence_countconsecutive messages suggesting the same different agent → triggerllm_classify_intent()to confirm, then switchExplicit Triggers
In addition to strategy-based auto-routing, users can always force an immediate switch via:
/agent <name>— existing slash command@agent-nameinline mention (e.g.,@coder fix this function) — triggers re-routing immediately, bypassing the divergence counterBoth reset the divergence counter and update the cached route.
Why the Divergence Counter Matters
Without it, a single ambiguous message causes a false switch:
Sticky Bonus Example
Interaction with Existing Features
Two-Layer Architecture
resolve_or_fallback): User bindings, thread routes, channel defaults determine which agent receives the messageresolve_assistant_target): Only fires when resolved agent is the"assistant"templateAuto-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.
ops-agentops-agent(not assistant, no auto-routing)assistantassistant(current behavior)assistantassistantthen auto-routes to specialistassistantRe-routing Behavior
/agent <name>or@agent-name: always overrides, resets cache and divergence counterIntent 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:
Out of scope for the initial implementation but should be considered in the design.
Implementation Scope
Files to modify (~6):
crates/librefang-types/src/config/types.rsAutoRouteStrategyenum, config fields onChannelOverridescrates/librefang-channels/src/types.rsSenderContextcrates/librefang-channels/src/bridge.rsbuild_sender_context(),@agent-namemention parsingcrates/librefang-kernel/src/kernel.rsresolve_assistant_target(),route_assistant_by_metadata_scored()helper, divergence counter on route cachecrates/librefang-kernel/src/router.rsdocs/channel-auto-routing.mdBackward compatible:
auto_routedefaults tooff.Open Questions
divergence_countof 2 the right balance? Should it be 3 for noisier channels?@agent-namementions be case-insensitive and support partial matches (e.g.,@opsmatchingops-agent)?Alternatives Considered
No response
Additional Context
No response