Stage-Router Routing
Stage-Router Routing
Stage-router routing sends each request to either a capable model or a
cheaper efficient one, depending on where the agent is in its run. The goal
is to spend the capable model on the turns that need it (exploration, error
recovery, hard reasoning) and let the efficient model carry the routine,
mechanical work. Which tier a turn defaults to depends on the picker you choose
(capable_first or efficient_first); the signals then move individual turns
off that default. You configure it with a single knob, confidence_threshold,
plus an optional LLM classifier.
If the selected backend hits a context-window overflow, the router retries once
against fallback_target_on_evict; a second overflow surfaces a
context-pool-exhausted error (see Context-Window Handling).
How it works
A coding agent’s run moves through stages: early on it explores the codebase and recovers from errors, and later it settles into more mechanical implementation. Those stages call for different amounts of model capability, which is what the router keys on.
For each LLM call, stage-router estimates which stage the agent is in from the tool-result history on the conversation, scoring two axes:
- WRONG → capable:
severity(windowed error severity),spinning(deep churn with no reads or writes), andexploring(reading or planning without producing) push toward the capable tier. - PROGRESS → efficient:
recent_production_intensity(writes and edits landing over the recent window) pushes toward the efficient tier.
The axes are corroborative: the signed score is tanh-squashed to a
confidence in [0, 1], so one full signal alone scores ~0.46 and a second
corroborating signal is what pushes it decisively past a 0.5 threshold. A
critical-error severity is a hard override that escalates on its own. The router
then routes:
- the capable tier for uncertain, exploratory, or error-recovery turns, and
- the efficient tier for settled, mechanical turns.
confidence_threshold sets how sure that estimate must be before the router acts
on the signal alone. Below it, the turn stays on the picker’s default tier (or,
if you added the optional classifier, goes to it first). A turn with no
tool-result history yet has no stage to estimate, so it takes the default tier.
The routing decision for one turn:
With capable_first, the default is capable, so a turn only reaches the cheaper
efficient model on a confident efficient signal (or an efficient verdict from
the classifier). Raising the threshold shrinks that path; lowering it widens it.
Pickers
The picker name says which tier is the default: the tier used when the signals are ambiguous and no classifier verdict is available.
capable_first: capable is the default; drop to efficient only when the signals (or the classifier) clearly say so. Quality-first.efficient_first: efficient is the default; escalate to capable only when the signals (or the classifier) clearly say so. Cost-first.
Both pickers read the same signals; only the default tier differs.
Tuning confidence_threshold
The scorer rates each turn from 0 (signals are neutral) to 1 (signals point
hard at one tier). confidence_threshold is the bar that rating has to clear
before the router will switch off the picker’s default tier. Clear it and the
router routes to the tier the signals indicate; fall short and the turn stays on
the default.
With the default capable_first picker, every turn starts on the capable tier
and only drops to the efficient tier when the signals say “efficient” and clear
the threshold. So the threshold sets how much evidence it takes to switch to the
cheaper tier:
- Raise it and only strong, decisive signals drop a turn to efficient, so the router stays on capable longer (more quality, more cost).
- Lower it and weaker signals are enough to drop to efficient, so more turns go cheap (more savings, more risk).
efficient_first is the mirror: turns start on efficient and need a signal that
clears the threshold to escalate to capable.
(If you add the optional classifier, sub-threshold turns go to it instead of staying on the default tier.)
Set 0.5 explicitly. It’s the recommended starting point and what the
example below uses. When you omit the field the config default is 0.5 (for
both the profile config and the deprecated route bundle) — but setting it
explicitly keeps the intent clear.
The signal-vs-classifier split is dataset-dependent. Measure it in
production via routing_decisions.stage_router on /v1/stats rather than relying on
priors from this doc.
Calibrating the threshold from run data
The recommended 0.5 starting point was derived from SWE-Bench Pro Python-75
calibration. To tune for a different task set or model pair, follow this
minimum-data path.
What you need
Neither run needs to cover the full task set. A few dozen capable tasks gives enough outcome diversity; the efficient probe only needs to cover the interesting quadrant candidates identified from those capable results.
How to sample the efficient probe set
Stratify the pure-capable results across four quadrant candidates before running efficient:
Sample across repos and diff sizes. Don’t over-represent one project.
Building RESCUE / LOSS quadrants
From the overlap tasks (those with both capable and efficient results):
RESCUE= capable-fail ∩ efficient-pass → escalation is beneficial hereLOSS= capable-pass ∩ efficient-fail → do NOT escalate hereSAFE= both passHARD= both fail
Running the sweep
Replay your runs through the real Rust scorer and picker with
benchmark/score_staged_run.py (the switchyard-stage-router-scorer skill). It emits
per-turn scores and per-task routing splits at a given threshold and window —
the actual pick_capable_first / pick_efficient_first decisions, not a
counterfactual:
Sweep a few candidate thresholds and read the routing split and pass rate off
the per-task CSV; the lowest threshold that rescues the RESCUE quadrant without
over-escalating the LOSS quadrant is your calibrated value. Because the scorer
is corroborative, a 0.5 threshold takes ~1.5 signals of agreement — a policy
that escalates ~20% of tasks maps roughly to confidence_threshold: 0.5 with
capable_first.
Signals come from the actual picker replay, so even 15–20 probe tasks give a stable result.
Caveat on efficient outcomes in stage-router vs. pure-efficient
In stage-router, the efficient model may inherit partial context from the capable arm (conversation history up to the escalation point). Pure-efficient runs start fresh, so RESCUE is a conservative lower bound. Efficient performs at least as well in stage-router as it does alone.
Route configuration
Save as routes.toml and start the server:
This is the recommended default: routing on tool signals alone, no classifier.
<<<<<<< HEAD:docs/routing_algorithms/stage_router_routing.mdx
fallback_target_on_evict is required and must reference one of the
declared target ids. See Context-Window Handling for
exception types and error envelopes.
Optional: handoff notes
origin/main:docs/routing_algorithms/stage_router_routing.md
Add a [routes.stage.handoff_notes] section to pass a contextual note to the
model the router switches to. The escalation note is sent to the capable tier on
a signal-driven escalation; the de-escalation note is sent back to the efficient
tier when a settled signal drops the turn there.
Optional: per-tier system prompts
Optional: LLM classifier fallback
By default the router uses tool signals only. To break ties on low-confidence
turns with a model call, add a [routes.stage.classifier] block and set
confidence_threshold above 0.0. The classifier is consulted only for turns
that fall below the threshold:
Give the classifier its own LLM client or quota bucket where possible. Sharing one provider bucket with the efficient tier adds a request per classified turn and can cause sustained 429s at scale.
Observability
Each response carries two routing headers:
Decision sources
The decision_source recorded internally for each turn explains why the routing
went the way it did. It appears in per-tier metrics tagged on the decision:
When not to use stage-router
- Single-model deployments. Use a
modelroute instead. - Probabilistic A/B splits. Use
Random Routing (
type = "random"). The stage-router’s signals are wasted on a fixed traffic ratio. - No tool-result history. Stage-router needs meaningful tool-call traffic to populate the tool-result signal. For pure chat-completion workloads every ambiguous request lands on the picker’s default tier.
Related
- Switchyard Architecture: the end-to-end request lifecycle and system boundaries.