Routers - agbossjs/agboss GitHub Wiki
Two things carry the name: the @Router flow node — a component that picks where a run goes
next — and the router engine underneath, which picks one option for a piece of text and knows
nothing about graphs or agents.
import { Router, Self } from "agboss";
@Router({
name: "review-gate",
description: "Sends the code back or opens the pull request",
prompt: "Is the review clean?", // or files next to the router: promptUrls: ["./review-gate.prompt.md"]
model: "typesafe/jev-1.13", // the router's own model: a decision model, or any chat model
maxVisits: 3, // required when the router is on a cycle, no default
routes: [
{ prompt: "The review asks for changes", target: CoderAgent },
{ prompt: "Stop and open the pull request: the review is clean", target: PullRequestWorkflowFinish },
{ prompt: "Ask the same agent again", target: Self },
],
})
export class ReviewGateRouter {}In the flow: from(ReviewGateRouter).routes() — the targets are the router's routes, the one list
of them (Workflow).
- The router's
prompt(orpromptUrls) says how to choose; each route'spromptsays what that choice means. A route may take its text from files too (promptUrls). A router without a prompt fails assembly (router.no-prompt), an empty route text too (router.empty-route-text). -
maxVisitsbounds the cycles through the router: a router on a cycle (router → agent → router, or aSelfroute) must declare it, otherwise assembly fails withrouter.unbounded-cycle; a router only on a path may omit it. A run that passes it fails withLimitExceededError(routers.<name>.maxVisits).limits.perRun.stepsstays the run-wide limit.agboss g routerwritesmaxVisits: 1;agboss create's main router hasmaxVisits: 3. - Routes are sent sorted by target name (
Selfasself) — declaration order changes neither the request nor its fingerprint.Selfleads back to the agent that ran last. - A route's target may be
parallel(...)/optional(...)(Advanced Routing). - The model is a decision model (
DECISION_MODELS: Jev,openai/gpt-6-luna-decisions, …, served byDecisionsModelProvider: one choice question over the routes, probabilities, exact cost) or a chat model (answers with the option, validated). One route → no model call. - No guessing: a failed model call fails the run (
RouterDecisionError,router.failed, with the model's reason and spend); an option that is not a route →router.unknown-route.
There is no built-in "finish" option: "return to the user" is a route to a workflow finish.
Word that route as a stop instruction that says when to take it — job-scout's reads "Stop and send
the answer: the contributions so far answer the message, or the last agent asked the job seeker a
question and waits for the reply, or it cannot be done". A terse text ("the last answer fully covers
the message") made Jev send the turn back to the agent that had just answered until maxVisits
failed the run.
@Router({
name: "main",
description: "Sends the job seeker's message to the right agent, or sends the answer",
prompt:
"Pick who handles the job seeker's message next. Send the answer when the contributions so far already cover the message.",
model: "typesafe/jev-1.13",
maxVisits: 3,
routes: [
{ prompt: "Reading the resume and proposing a search brief", target: Profiler },
{ prompt: "Finding and ranking jobs", target: Scout },
{ prompt: "Saving chosen jobs to the shortlist, or showing it", target: Shortlist },
{
prompt:
"Stop and send the answer: the contributions so far answer the message, or the last agent asked the job seeker a question and waits for the reply, or it cannot be done",
target: ChatWorkflowFinish,
},
],
})
export class MainRouter {}maxVisits: 3 — one message may pass through the router at most three times (two agents, then the
answer); a fourth visit fails the run (routers.main.maxVisits).
A RouterEngine (root entry) picks one option for a text:
interface RouterEngine {
readonly name: string;
readonly route: (request: RouteRequest) => Promise<RouteOutcome>;
}
interface RouteRequest {
readonly input: MessageContent;
readonly options: readonly { readonly name: string; readonly description: string }[];
readonly instructions?: string;
}
type RouteOutcome =
| { kind: "decided"; decision: { next: string; reason: string; confidence?: number }; usage?: UsageRecord }
| { kind: "failed"; reason: string; usage?: UsageRecord; unknownOption?: string };| Kind | How it decides | Cost source |
|---|---|---|
jev |
OpenRouter Decisions API, a choice question, probabilities |
reported by the API |
llm |
a chat model replies with the option, validated | tokens × price table |
Failures (HTTP error, malformed output, unknown option) are failed outcomes, never exceptions. One
option → decided without a model call; no options → failed without a call (usage absent).
Routers default to Jev — see ADR-0001.
Components use an engine for cheap decisions of their own through the ROUTER_FACTORY token —
job-scout's fit judge:
import { Injectable, ROUTER_FACTORY, type RouterEngine } from "agboss";
@Injectable({ deps: [ROUTER_FACTORY] })
export class JobFitJudge {
private readonly router: RouterEngine;
constructor(routers: (name: string) => RouterEngine) {
this.router = routers("job-fit"); // the model is defaults.router
}
async fits(profile: string, job: string): Promise<boolean> {
const outcome = await this.router.route({
instructions: FIT_QUESTION,
input: `Candidate wants:\n${profile}\n\nJob:\n${job}`,
options: [
{ name: "fit", description: "The job fits" },
{ name: "no_fit", description: "The job does not fit" },
],
});
return outcome.kind === "decided" && outcome.decision.next === "fit";
}
}- Inside the framework,
src/routersimports onlyconfig,finops,llm; the rest ofsrcimports routers only viasrc/routers/index.ts(ESLint-enforced). The engines' own tests are inpackages/agboss/tests/routers/— plainRouteRequestfixtures, no graph, no agents. - The graph renders the task, the contributions so far and the memory (
defaults.history) into the router'sinput.
In a workflow test a router answers by script — routeTo(Target) — or by a function of the request
(Testing):
import { expect } from "vitest";
import { replyWith, routeTo, testWith } from "agboss/testing";
const test = testWith(Desk);
test("help goes to support", async ({ app, mockLlm }) => {
mockLlm(MainRouter).thenAnswer((request) =>
request.kind === "decision" && request.input.toString().includes("help")
? routeTo(SupportAgent)
: routeTo(ChatFinish),
);
mockLlm(SupportAgent).thenReturn(replyWith("On it."));
const result = await app.execute(ChatStart, { text: "help" });
expect(result).toFollowPath([ChatStart, MainRouter, SupportAgent, MainRouter, ChatFinish]);
});app.router(MainRouter).routeTo("…") (the app fixture) asks one router alone with its scripted
model.