Skip to content
← How we build

backend/src/router/router.py

"""Multi-phase confidence router with structured trace output."""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass
from typing import Any

from agents.base.agent_context import AgentContext
from core.trace_context import PatternMatchDetail, RequestTrace, RoutingTrace
from core.tracing import (
    log_pattern_match,
    log_routing_decision,
    log_routing_guard,
    log_routing_phase,
    log_routing_start,
)
from models.routing import RoutingDecision
from observability import log_event, record_metric
from router.config_loader import load_routes, load_thresholds
from router.entities import extract_entities
from router.pattern_matcher import RouteScore, score_routes

logger = logging.getLogger("agentic.request")
trace_logger = logging.getLogger("agentic.trace")

BLOCKED_PATTERNS = ("ignore previous instructions", "jailbreak")


@dataclass
class RouteResult:
    routing: RoutingTrace
    execute_skill: str = "chat"


def _to_pattern_details(scores: list[RouteScore]) -> list[PatternMatchDetail]:
    return [
        PatternMatchDetail(
            route_id=score.route_id,
            pattern=score.pattern,
            confidence=score.confidence,
            entities=score.entities,
            matched=score.matched,
        )
        for score in scores
    ]


def _best_match(scores: list[RouteScore]) -> RouteScore | None:
    for score in scores:
        if score.matched and score.confidence > 0:
            return score
    return None


def route_request(trace: RequestTrace, message: str) -> RouteResult:
    log_routing_start(trace, router_enabled=True)
    log_event(
        "begin",
        component="Router",
        trace_id=trace.trace_id,
        fields={"message_preview": message[:80]},
    )
    trace_logger.debug(
        "routing_begin_detail trace_id=%s message_len=%s",
        trace.trace_id,
        len(message),
    )

    thresholds = load_thresholds()
    high_confidence = float(thresholds.get("high_confidence", 0.70))
    mid_confidence = float(thresholds.get("mid_confidence", 0.40))
    phase2_trigger = float(thresholds.get("phase2_trigger", 0.25))

    lowered = message.lower()
    for guard in BLOCKED_PATTERNS:
        if guard in lowered:
            log_routing_guard(trace, guard=guard, blocked=True)
            routing = RoutingTrace(
                router_enabled=True,
                phase_reached="phase0",
                guard_triggered=guard,
                intent="blocked",
                destination_type="skill",
                destination_id="chat",
                decision_reason=f"Phase 0 guard matched: {guard}",
            )
            trace.routing = routing
            log_routing_decision(trace, routing)
            return RouteResult(routing=routing, execute_skill="chat")

    log_routing_guard(trace, guard="none", blocked=False)
    log_routing_phase(trace, phase="phase1", reason="pattern and entity matching")

    scores = score_routes(message, load_routes())
    pattern_details = _to_pattern_details(scores)
    for detail in pattern_details:
        log_pattern_match(trace, detail)

    winner = _best_match(scores)
    if winner is None:
        confidence = 0.0
    else:
        confidence = winner.confidence

    if winner and confidence >= high_confidence:
        routing = RoutingTrace(
            router_enabled=True,
            phase_reached="phase1",
            pattern_matches=pattern_details,
            winning_route_id=winner.route_id,
            intent=winner.route_id,
            confidence=confidence,
            destination_type=winner.destination_type,
            destination_id=winner.destination_id,
            decision_reason=(
                f"Phase 1 high confidence ({confidence:.2f} >= {high_confidence:.2f})"
            ),
        )
        trace.routing = routing
        log_routing_decision(trace, routing)
        return RouteResult(
            routing=routing,
            execute_skill="chat" if winner.destination_type == "skill" else "chat",
        )

    if winner and confidence >= mid_confidence:
        routing = RoutingTrace(
            router_enabled=True,
            phase_reached="phase1",
            pattern_matches=pattern_details,
            winning_route_id=winner.route_id,
            intent=winner.route_id,
            confidence=confidence,
            destination_type=winner.destination_type,
            destination_id=winner.destination_id,
            decision_reason=(
                f"Phase 1 mid confidence ({confidence:.2f} >= {mid_confidence:.2f})"
            ),
        )
        trace.routing = routing
        log_routing_decision(trace, routing)
        return RouteResult(
            routing=routing,
            execute_skill="chat",
        )

    if confidence < phase2_trigger:
        log_routing_phase(
            trace,
            phase="phase2",
            reason=(
                f"confidence {confidence:.2f} below phase2_trigger {phase2_trigger:.2f}"
            ),
        )
        routing = RoutingTrace(
            router_enabled=True,
            phase_reached="phase2",
            pattern_matches=pattern_details,
            winning_route_id=winner.route_id if winner else None,
            intent=winner.route_id if winner else "general_chat",
            confidence=confidence,
            destination_type="skill",
            destination_id="chat",
            phase2_llm_used=False,
            decision_reason=(
                "Phase 2 classifier not implemented — fallback chat skill (no classifier LLM call)"
            ),
        )
        trace.routing = routing
        log_routing_decision(trace, routing)
        return RouteResult(routing=routing, execute_skill="chat")

    log_routing_phase(
        trace,
        phase="fallback",
        reason=f"confidence {confidence:.2f} in ambiguous band",
    )
    routing = RoutingTrace(
        router_enabled=True,
        phase_reached="fallback",
        pattern_matches=pattern_details,
        winning_route_id=winner.route_id if winner else None,
        intent="general_chat",
        confidence=confidence,
        destination_type="skill",
        destination_id="chat",
        decision_reason="Ambiguous confidence — default chat skill",
    )
    trace.routing = routing
    log_routing_decision(trace, routing)
    return RouteResult(routing=routing, execute_skill="chat")


def _required_skills_for(route_id: str, routes: list[dict[str, Any]]) -> list[str]:
    for route in routes:
        if route.get("id") == route_id:
            return list(route.get("required_skills", []))
    return []


def _emit_route_complete(
    decision: RoutingDecision,
    trace_id: str,
    session_id: str | None,
    started_at: float,
    *,
    guard: str | None = None,
) -> None:
    latency_ms = (time.perf_counter() - started_at) * 1000
    fields: dict[str, Any] = {
        "session_id": session_id,
        "route_name": decision.route_name,
        "skills": decision.skills,
        "confidence": decision.confidence,
        "low_confidence": decision.low_confidence,
        "latency_ms": round(latency_ms, 2),
    }
    if guard is not None:
        fields["guard"] = guard
    log_event("route_complete", component="Router", trace_id=trace_id, fields=fields)
    record_metric(
        "router.latency_ms",
        latency_ms,
        tags={"matched": "true" if decision.route_name else "false"},
    )
    record_metric(
        "router.matched",
        1.0 if decision.route_name else 0.0,
    )


def route(query: str, agent_context: AgentContext) -> RoutingDecision:
    """Score the raw query and produce a `RoutingDecision` (`routing-design.md` §2/§6, Revision 6).

    Takes only `query` + `AgentContext` — never `RequestContext` (see the Router-scores-
    raw-query invariant). Reuses `score_routes()`/`extract_entities()` unchanged; this
    function only adds tiering (`low_confidence`), multi-skill resolution, and the
    concrete no-route-matched shape on top of them.
    """
    trace_id = agent_context.trace_id
    session_id = agent_context.session_id
    started_at = time.perf_counter()
    log_event(
        "route_start",
        component="Router",
        trace_id=trace_id,
        fields={"session_id": session_id, "message_preview": query[:80]},
    )

    lowered = query.lower()
    for guard in BLOCKED_PATTERNS:
        if guard in lowered:
            decision = RoutingDecision(
                route_name=None,
                skills=[],
                confidence=0.0,
                matched_entities={},
                low_confidence=False,
                reasoning=f"Phase 0 guard matched: {guard!r} — routed to fallback chat",
            )
            _emit_route_complete(decision, trace_id, session_id, started_at, guard=guard)
            return decision

    thresholds = load_thresholds()
    high_confidence = float(thresholds.get("high_confidence", 0.70))
    fallback_margin = float(thresholds.get("fallback_margin", 0.30))
    low_confidence_floor = high_confidence - fallback_margin

    routes = load_routes()
    entities = extract_entities(query)
    scores = score_routes(query, routes)
    winner = _best_match(scores)
    best_score_seen = scores[0].confidence if scores else 0.0

    if winner and winner.confidence >= high_confidence:
        decision = RoutingDecision(
            route_name=winner.route_id,
            skills=_required_skills_for(winner.route_id, routes),
            confidence=winner.confidence,
            matched_entities=entities,
            low_confidence=False,
            reasoning=(
                f"High confidence ({winner.confidence:.2f} >= "
                f"high_confidence {high_confidence:.2f})"
            ),
        )
    elif winner and winner.confidence >= low_confidence_floor:
        decision = RoutingDecision(
            route_name=winner.route_id,
            skills=_required_skills_for(winner.route_id, routes),
            confidence=winner.confidence,
            matched_entities=entities,
            low_confidence=True,
            reasoning=(
                f"Low confidence, within fallback margin ({winner.confidence:.2f} in "
                f"[{low_confidence_floor:.2f}, {high_confidence:.2f})) — selected with "
                f"low_confidence=true"
            ),
        )
    else:
        decision = RoutingDecision(
            route_name=None,
            skills=[],
            confidence=best_score_seen,
            matched_entities=entities,
            low_confidence=False,
            reasoning=(
                f"No route matched (best score seen {best_score_seen:.2f} below "
                f"fallback floor {low_confidence_floor:.2f})"
            ),
        )

    _emit_route_complete(decision, trace_id, session_id, started_at)
    return decision

Follow the work.

Occasional updates on SignalFoundry, MarketCompass, and what we are building at CompassFoundry Labs.

No spam. Unsubscribe anytime.