Semantic Routing with Decision Branch

Applies to: ChengOS v0.2.0+ | Last verified: 2026-09-25 | Source: crates/cheng-nodes/src/nodes/builtin/ai/decision_branch.rs, config/decision_calibration.toml, crates/cheng-llm/tests/fixtures/decision/, scripts/decision/cascade_eval.py

A single general agent with every tool attached works, but it is slow, expensive, and holds more privilege than most turns need. Semantic routing puts an ai/decision_branch in front: each turn is sent to a focused sub-flow — email, calendar, weather — when one clearly applies, and to the general agent otherwise.

This guide is about building that router well. The providers behind it are covered in Decision Providers & TypeSafe Jev; every node field is in the Decision Branch Node reference.

The shape of a router

chat/input ──► ai/decision_branch
                 ├─ route_email     ──► email agent      (mail tools only)
                 ├─ route_calendar  ──► calendar agent   (calendar tools only)
                 ├─ route_weather   ──► weather lookup
                 ├─ fallback_out    ──► general agent    (the workflow as it was)
                 └─ decision        ──► optional: log / trace the routing summary

Three properties make this safe to add to an existing workflow:

Writing routes

Each route has three fields:

FieldRulesSeen by the provider
id1–64 characters: lowercase letters, digits, _, -. Names the port route_<id>. __fallback__ is reserved.Yes, as the choice ID
labelUp to 80 charactersNo — UI only
description1–2000 characters; one or two sentencesYes — this is what the model reads

Write descriptions as capabilities, not keywords. "weather" is a poor description; "Check current weather, temperature, or rain forecasts for a place" is a good one. The model chooses by comparing the request with these sentences, so vague or overlapping descriptions produce low-confidence answers that fall back.

Keep routes disjoint. If two routes could both handle a request, the provider splits its probability between them and neither clears the threshold. Merge them, or sharpen the difference in the descriptions.

Stay within the limits. At most 16 routes, and at most 15 on Laya (the fallback takes one option). Laya's English model also has a small instruction budget; eight routes with the default instruction already exceed it, and the editor reports the refusal.

Edges follow IDs, not positions. Reordering routes never moves an edge. Renaming an id does — its port changes name.

The Routing Instruction (under advanced settings) replaces the default: "Choose the route that can satisfy the user's current request with the least privilege. Choose a route only when its capability is required. If the request needs more than one route, or none clearly matches, choose the fallback route." Keep tool-operation instructions on the focused agents, not here.

Thresholds and failures

Threshold Mode

ModeBehavior
Calibrated (default)Apply the measured profile for this provider, model, and language. No profile means the turn falls back.
NoneRoute on the raw answer. Only invalid, out-of-set, timed-out, or failed decisions fall back. Required for the LLM baseline.

Failure Mode — Fallback (default) sends provider errors and missing configuration to fallback_out; Fail closed fails the node with a typed error instead. Use fail-closed only when running the general agent on an unrouted turn would itself be wrong.

Timeout — empty uses the provider default (1500 ms for Jev and Laya; adaptive for the LLM baseline). A missed deadline follows the failure mode and never stalls the turn.

Sticky routing for follow-ups

"Reply to him and say yes" cannot be routed on its own; it only makes sense after the previous turn. With Route Memory = Sticky (the default):

Memory is kept per workflow, node, and conversation for six hours, in the API process. In a multi-instance deployment or after a restart a follow-up may reach the general agent instead of being held — safe by construction, never a wrong route. Laya is never sent the previous route, so sticky routing has no effect on it. Set Route Memory to Off to route every turn on its own.

A two-stage cascade

A cheap local model can decide the easy turns and hand only the hard ones to a stronger one:

stage 1: decision_branch (Laya, Calibrated, Uncertain Decisions = Separate port)
           ├─ route_*       ──► the focused sub-flows
           ├─ fallback_out  ──► general agent          (confident "none of these")
           └─ uncertain_out ──► stage 2
stage 2: decision_branch (LLM baseline, Threshold Mode = None, same routes)
           ├─ route_*       ──► the same sub-flows
           └─ fallback_out  ──► general agent

With Uncertain Decisions = Separate port, only turns that were below the threshold or had no profile go to uncertain_out. Confident fallbacks and provider failures still go straight to fallback_out. Give stage 2 exactly the same route IDs and descriptions.

Measure the cascade on your own deployment before quoting latency or accuracy: both depend on your hardware, the chat model, and your routes. scripts/decision/cascade_eval.py replays the labeled routing-eval-v2 set through this exact shape against your chat model endpoint, and crates/cheng-llm/tests/fixtures/decision/ holds the recorded runs it compares against.

Reading the decision summary

The decision output port (and metadata["decision_route"]) carries a text-free summary: selected route, reason, provider, model, language, calibration profile, probabilities, latency. The reason tells you why a turn went where it did:

ReasonMeaning
selectedA route cleared the threshold (or threshold mode is None)
sticky_holdThe previous route was held for a follow-up
fallback_selectedThe provider confidently chose "none of these"
uncertain_fallback / below_thresholdThe answer was not confident enough
uncalibratedCalibrated mode, but no profile for this provider, model, and language
empty_requestThere was no user text to route
timeout, authentication, rate_limited, unavailable, out_of_set, …Provider failure, handled by the failure mode

A high share of uncalibrated means you are using a model or language without a profile. Many below_threshold turns usually mean overlapping descriptions.

Checklist

Next steps