Skip to content

Routing

Not every question needs a model. "The dashboard is down" deserves the status page, at once and for free. A router answers one question with one of a fixed set of options, here "what kind of question is this?". Your code then takes the cheapest path that serves it.

Declare a router

Add a routing section with a router called intent:

harness.yaml (the routing section)
routing:
  intent:
    options: [outage, billing, howto]
    cascade:
      - id: outage-words
        use: rules
        rules:
          - { when: "text.matches('(down|outage|not loading)')", choice: outage }
      - id: examples
        use: lexical
        threshold: 0.3
        utterances:
          billing: [I was charged twice, where is my invoice, how long does a refund take]
          howto: [how do I export a report, how do I add a user to my team]
    default: howto

A router is a cascade of stages, tried in order. The first confident answer wins:

  1. outage-words is a rules stage. It's exact and deterministic: the first rule whose cel-lite when is true decides. Here, any question that mentions "down", "outage" or "not loading" is an outage.
  2. examples is a lexical stage. It compares the question with example sentences for each option and picks the closest. Its answer counts only when its confidence reaches threshold.
  3. When no stage is confident, the router's default answers.
flowchart LR
    Q[question] --> R1{rules<br/>exact match?}
    R1 -- yes --> D[decision]
    R1 -- no --> R2{lexical<br/>confidence >= 0.3?}
    R2 -- yes --> D
    R2 -- no --> DF[default: howto] --> D

Ask it in your steps

helpdesk/flows.py
"""The answer task: our own steps, each one governed by the harness."""

import harnessweaver as hw

OUTAGE_REPLY = "We know about the outage and are fixing it. Updates: status.acme.example"


async def answer(s: hw.Session, question: str) -> str:
    """Answer a customer's question in the support desk's voice."""
    route = await s.decide("intent", text=question)
    if route.choice == "outage":
        return OUTAGE_REPLY
    return await s.generate(prompt="answer@1", question=question)

s.decide returns a Decision:

Field Holds
choice The option chosen, or None when the router abstained
kind exact (a rules stage), probabilistic (a scored stage), default or abstain
confidence None for exact answers, else 0 to 1
stage The id of the stage that decided
reason Why, without the question's text

Run it

Ask one question of each kind, and print the routing notes from each trace:

main.py
import asyncio

import harnessweaver as hw
from harnessweaver.testing import render_trace

QUESTIONS = ["The dashboard is down, what do I do?", "How long does a refund take?"]


async def main() -> None:
    harness = hw.load("harness.yaml")
    for question in QUESTIONS:
        result = await harness.run("answer", question, principal="ann", tenant="acme")
        print(result.output)
        routing = [e for e in result.trace if e.source in ("harness.routing", "harness.decision")]
        print(render_trace(routing))


if __name__ == "__main__":
    asyncio.run(main())
$ HARNESSWEAVER_ENV=offline uv run python main.py
We know about the outage and are fixing it. Updates: status.acme.example
   5 note    route.decide      harness.routing: rule 1
   8 note    route.decide      harness.decision: rule 1
Refunds go back to the card you paid with within five business days.
   5 note    route.decide      harness.routing: no rule matched
   6 note    route.decide      harness.routing: closest to billing
   9 note    route.decide      harness.decision: closest to billing

The outage question was decided by the first rule, and the model was never called. The refund question passed the rules stage, and the lexical stage found it closest to billing. Every stage's answer is in the trace, including the stages that lost, so you can see why each question went where it did (RULES R13).

Stages

Stage Kind How it decides
rules Deterministic The first {when, choice} rule whose cel-lite when is true. when can read text, state, options and router
lexical Deterministic Token overlap (cosine) with each option's example utterances
semantic Deterministic with the built-in embedder Embedding similarity with the examples. The built-in semantic.hashing embedder needs no model; a plugin can bring a real one
llm Probabilistic Asks a model to pick an option, with a confidence. Governed and budgeted like any model call

Put cheap, exact stages first and probabilistic ones after them. Tune each threshold on a labelled set of questions, because a stage's confidence isn't calibrated. Each stage's settings are in Providers. You can also write your own stage as a plugin (Runners, deciders and adapters).

Routing is not permission

A router may decide which path a request takes. It must never decide whether something is allowed. Permission belongs to policy, which must be deterministic (RULES R3). A probabilistic stage can route a request, but it can never grant anything.

Recap

  • A router answers with one of its options, through a cascade of stages: the first confident answer wins, then the default.
  • s.decide("intent", text=...) asks it from your steps. Every stage's answer is in the trace.
  • Use cheap, exact stages first, and never use routing to grant permissions.