routeSwitch
Route to one of several steps by a decision's answers.
Quick Example
import { callModel, decide, routeSwitch } from '@noetic-tools/core';
const triage = decide({
id: 'triage',
model: 'typesafe/jev-1.14',
questions: {
destination: {
type: 'choice',
instructions: 'Which team should handle this ticket?',
choices: ['billing', 'technical-support', 'sales', 'spam'],
},
},
});
const reply = (id: string, instructions: string) =>
callModel({
id,
model: 'openai/gpt-4o-mini',
instructions,
});
const handleTicket = routeSwitch({
id: 'route-ticket',
decide: triage,
on: 'destination',
cases: {
billing: reply('billing', 'Resolve the billing question.'),
'technical-support': reply('support', 'Debug the reported problem.'),
sales: reply('sales', 'Answer the pricing question.'),
spam: reply('spam', 'Reply with a short, polite decline.'),
},
minConfidence: 0.6,
uncertain: reply('ask-human', 'Ask a human to triage this ticket.'),
});What It Does
routeSwitch runs a decide step on its input, then runs the case its answers pick. The chosen case receives the original input, not the answers, and its output is the switch's output.
routeSwitch is a conditional built from a decision, not a new step kind. It runs the decide step through the run argument every conditional route receives, so the decision gets its own span and its onDecision notification, just as if it ran on its own. The decide step and every case are listed in _optimizable, so tree walks (tool collection, GEPA) see every child. The switch can also rebuild itself around new children, so when GEPA swaps a case or the decide step, the swapped step is the one that runs.
Two switches over the same decide step, one nested in the other, call the model once: the inner one reuses the outer one's answers (see Asking again).
There are two forms: route by one question (key form), or combine answers in a function (function form).
Key Form
Set on to a question id. cases must have exactly one step per outcome of that question, and uncertain is required:
| Question type | Case keys |
|---|---|
choice | each choice |
boolean | 'true' and 'false' |
score | each level label (routed by level, the most probable level) |
import { decide, routeSwitch, runCode } from '@noetic-tools/core';
const check = decide({
id: 'check',
model: 'typesafe/jev-1.14',
questions: {
isUrgent: {
type: 'boolean',
instructions: 'Is the customer reporting downtime?',
},
urgency: {
type: 'score',
instructions: 'How urgent is this ticket?',
levels: ['low', 'medium', 'high'],
},
},
});
const page = runCode({ id: 'page', execute: async (input: string) => `paged: ${input}` });
const queue = runCode({ id: 'queue', execute: async (input: string) => `queued: ${input}` });
const triageByHand = runCode({ id: 'by-hand', execute: async (input: string) => `needs a human: ${input}` });
const byBoolean = routeSwitch({
id: 'by-boolean',
decide: check,
on: 'isUrgent',
cases: { true: page, false: queue },
minConfidence: 0.8,
uncertain: triageByHand,
});
const byLevel = routeSwitch({
id: 'by-level',
decide: check,
on: 'urgency',
cases: { low: queue, medium: queue, high: page },
uncertain: triageByHand,
});A missing case, a misspelled extra case, or an on that names no question is a type error.
Uncertain answers
The key form takes uncertain when:
- the routed answer is a refusal, or
minConfidenceis set and the answer's confidence is below it.
Confidence is the provider's confidence for choice and score answers. A boolean answer has none, so its confidence is the probability of the side value picked: probability for yes, 1 - probability for no. That respects the question's threshold: with threshold: 0.7 and probability: 0.6, value is false and its confidence is 0.4.
Because uncertain is required, every outcome runs a typed step. The switch never passes its input through as if it were a case's output.
Output Type
The switch's output type is the union of every case's output, plus uncertain (and default in the function form). Cases may return different types:
import { decide, routeSwitch, runCode } from '@noetic-tools/core';
const check = decide({
id: 'check',
model: 'typesafe/jev-1.14',
questions: {
isUrgent: {
type: 'boolean',
instructions: 'Is the customer reporting downtime?',
},
},
});
const page = runCode({ id: 'page', execute: async (input: string) => ({ paged: input }) });
const queue = runCode({ id: 'queue', execute: async (input: string) => input.length });
const triageByHand = runCode({ id: 'by-hand', execute: async (input: string) => `needs a human: ${input}` });
// StepConditional<..., string, { paged: string } | number | string>
const handle = routeSwitch({
id: 'handle',
decide: check,
on: 'isUrgent',
cases: { true: page, false: queue },
uncertain: triageByHand,
});StepInput<S> and StepOutput<S>, exported from @noetic-tools/core, pull a step's input and output types out of its type.
Function Form
Pass a function as on to combine several answers. It returns a case key; default is required and catches any key with no case.
import { decide, routeSwitch, runCode } from '@noetic-tools/core';
const check = decide({
id: 'check',
model: 'typesafe/jev-1.14',
questions: {
isUrgent: {
type: 'boolean',
instructions: 'Is the customer reporting downtime?',
},
urgency: {
type: 'score',
instructions: 'How urgent is this ticket?',
levels: ['low', 'medium', 'high'],
},
},
});
const pageOnCall = runCode({ id: 'page', execute: async (input: string) => `paged: ${input}` });
const backlog = runCode({ id: 'backlog', execute: async (input: string) => `queued: ${input}` });
const escalate = routeSwitch({
id: 'escalate',
decide: check,
on: (a) => (a.isUrgent.value && a.urgency.value >= 1.5 ? 'page' : 'queue'),
cases: { page: pageOnCall, queue: backlog },
default: backlog,
});The function never sees a refusal. If any answer is refused, the switch takes uncertain, or default when uncertain is unset, and on does not run. So on receives answers typed without Refusal and can read value directly.
API Reference
Key form options
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique step identifier |
decide | DecideStep | Yes | The decide step to run. Must come from decide(), which keeps the literal questions |
on | string | Yes | Id of the question to route by |
cases | Record<CaseKey, Step> | Yes | Exactly one step per outcome of on, and no other keys |
minConfidence | number | No | Below this confidence, take uncertain. In [0, 1] |
uncertain | Step | Yes | Taken on refusal or low confidence |
Function form options
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique step identifier |
decide | DecideStep | Yes | The decide step to run |
on | (answers) => string | Yes | Picks a case key from the answers, refusals removed |
cases | Partial<Record<C, Step>> | Yes | Steps by case key; keys must be ones on can return |
default | Step | Yes | Taken when on returns a key with no case, and on refusal when uncertain is unset |
uncertain | Step | No | Taken when any answer is refused |
Build-time errors
routeSwitch() throws NoeticConfigError when you build it:
| Code | Condition |
|---|---|
EMPTY_STEP_ID | empty id |
UNKNOWN_ROUTE_QUESTION | key form: on names no question of decide |
INCOMPLETE_ROUTE_CASES | key form: an outcome of on has no case |
INVALID_MIN_CONFIDENCE | minConfidence outside [0, 1] |
MISSING_UNCERTAIN_STEP | key form: no uncertain |
Observability
Each run adds a route_switch event to ctx.span with stepId and case: the case taken, uncertain, or default.
Related
- decide -- the questions and answers a switch routes by.
- conditional -- the general routing primitive.
- Semantic Conditions --
semanticSwitchroutes by embedding similarity, with no model call. - JSON Runtime -- the
routeSwitchworkflow node.