Repository navigation
Advanced Routing
Patterns of routers on the paths (#272, #280): one choice, routers in a chain, a router in a loop,
a branch by the result of the chosen step, a router's failure, a rule decided by code. The grammar
is on Workflow, the router component on Routers. Everything here is in the
root entry agboss (start, router, route, caseOf, toAction, toFinish, …); parallel
launches, quorums and child workflows build on these paths later (see the end).
The nodes used below (ordinary components):
A router's choice lists its routes, each continued to its end. A route may lead to another router — here the front router hands money matters to a second one:
@Workflow({
name: "front-desk-workflow",
version: "1.0.0",
defaults: { router: { model: "typesafe/jev-1.13" } }, // the routers' model
flow: [
start(TicketWorkflowStart)
.returns(WorkflowStartTextDto)
.toRouter(FrontRouter)
// 4. the router's own failure: a failed call or a not-allowed answer goes to a finish
.onError(RouterFailedError, toFinish(UnroutedWorkflowFinish))
.chooseOne(
// 1. a route to another router: its choice is continued right there
route(MoneyRouter)
.chooseOne(
// 2. a branch by the result of the chosen agent
route(BillingAgent)
.returns(oneOf(AnswerDto, NeedsHumanDto))
.switch(
caseOf(AnswerDto)
.toFinish(AnswerWorkflowFinish),
caseOf(NeedsHumanDto)
.toFinish(EscalatedWorkflowFinish),
),
route(RefundAgent)
.returns(AnswerDto)
.toFinish(AnswerWorkflowFinish),
),
route(ChatAgent)
.toFinish(ReplyWorkflowFinish), // a text agent answers text
// 3. a router in a loop: the triage agent is reached from two places → its own path
route(TriageAgent),
route(UnroutedWorkflowFinish),
),
agent(TriageAgent)
.returns(TicketDto)
.toRouter(TicketRouter)
.chooseOne(
route(OpenTicketAction)
.returns(AnswerDto)
.toFinish(AnswerWorkflowFinish),
route(TriageAgent), // back: maxVisits bounds the loop
),
],
})
export class FrontDeskWorkflow {}An agent with a oneOf output continues with .returns(oneOf(…)).switch(caseOf(…)…) — one case per variant, each
its own path; a missing or unknown case fails assembly ([flow.switch-not-exhaustive],
[flow.switch-unknown-case]). Asking or refusing is just a case that leads to a finish.
A node reached from two or more places is continued in its own path (agent(TriageAgent)…,
router(R)…), and every path into it ends at it — the loop is visible. Every cycle needs
maxVisits on one of its nodes ([graph.cycle-without-max-visits]); a visit past it fails before
the model call with VisitsLimitExceededError, which .onError right after that step can take.
.onError(…) right after .toRouter(R) takes the router's failure (#280 S19); a side path with
toAction(…) can log it before its finish. Without a handler the run fails.
-
.onError(RouterFailedError, …)— any failure of the step: its model call or itsroute()failed (step.router), or its answer is not allowed. -
.onError(RouterDecisionError, …)— only a not-allowed answer (step.router.invalid-decision: a target outside its routes, an unknown or unreadable answer); never retried. -
.onError(DomainError, …)— the error a code router'sroute()(or the service it calls) threw, caught throughRouterFailedError's cause.
A model reads the text into a typed DTO; a code router decides by the type — no second model call, no matching text (Routers → Routers decided by code):
@Router({
name: "priority-router",
description: "Pages the on-call engineer for urgent messages, queues the rest",
decidedBy: DecidedBy.Code,
input: { priority: PriorityDto },
routes: [{ target: PageOnCallAction }, { target: QueueTicketAction }],
})
export class PriorityRouter implements RouterHandler<PriorityRouter> {
route(input: InputOf<PriorityRouter>): Route<PriorityRouter> {
return input.priority.value.isUrgent ? PageOnCallAction : QueueTicketAction;
}
}
@Workflow({
name: "priority-workflow",
version: "1.0.0",
flow: [
start(TicketWorkflowStart)
.returns(WorkflowStartTextDto)
.toAgent(PriorityAgent)
.returns(PriorityDto)
.toRouter(PriorityRouter) // a code router: the flow is the same as for a model router
.chooseOne(
route(PageOnCallAction)
.returns(AnswerDto)
.toFinish(AnswerWorkflowFinish),
route(QueueTicketAction)
.returns(AnswerDto)
.toFinish(AnswerWorkflowFinish),
),
],
})
export class PriorityWorkflow {}A code router may also sit inside a model router's route(…) — route(PriorityRouter).chooseOne(…)
— or the other way round; routers chain in any mix with no step between them.
After .toRouter(R) a parallel launch (#273) lists the router's routes ([router.routes-mismatch])
and its routes carry the launch's policies: spawn — SpawnPolicy.Always (the framework launches
it; no model sees it, so it has no prompt) or SpawnPolicy.OnDemand (the router decides; a model
router reads its route prompt) — and failure — FailurePolicy.FailFast (default) or
FailurePolicy.Skip (a failed agent drops out of the join). A value of an OnDemand or Skip
branch is optional(XDto) in .join().returns(…) and in every consumer's input.
@Router({
name: "support-router",
description: "Picks everyone needed for the customer's message",
decidedBy: DecidedBy.Model,
model: "typesafe/jev-1.13",
prompt: "Pick everyone needed for the customer's message.",
input: { message: WorkflowStartTextDto },
routes: [
{ target: OrderAgent, spawn: SpawnPolicy.Always },
{ target: RefundPolicyAgent, spawn: SpawnPolicy.OnDemand, prompt: "The message is about returns" },
{ target: LoyaltyAgent, spawn: SpawnPolicy.Always, failure: FailurePolicy.Skip },
],
})
export class SupportRouter {}| Rule | |
|---|---|
A model router states spawn
|
before a launch every route of a model router says spawn — [router.spawn-required]; an Always route has no prompt — [router.prompt-on-always] (a compile error too); an OnDemand one has one — [router.no-prompt]
|
| It decides something | at least one OnDemand route — [router.all-routes-always]
|
| A code router | its route is OnDemand unless it says spawn: SpawnPolicy.Always; before a launch its route(input) returns the OnDemand agents to launch — RouterAnswerOf<Self> is a list (readonly [Route<Self>, ...Route<Self>[]] when no route is Always, so [] does not compile) |
| Agents only | a router launches agents: .toParallelActions(…) after a router does not compile |
| Policies live in the router | with .chooseOne(…) they are [router.policy-with-choose-one]; a launch from an agent, a start or an action has none (all Always + FailFast) |
At run time (#273 PR 2, S21, S23):
| A model router decides in one call | one independent yes/no question per OnDemand route, its text the route's prompt, keyed by the target's name in the order of routes; the router's own prompt leads the state it decides on. A decision model (Jev) answers one decide with a Decision.noul per route; a chat model one answer tool (answer-launch) with a boolean field per route. A route is launched when its answer is yes — P(yes) above 0.5, no threshold setting; every probability goes into the trace ("launch decision"). Always routes are never shown to the model |
| All no | with an Always route only the Always targets run; with none, nothing would run → RouterDecisionError (step.router.invalid-decision), as for a code router's empty list |
| A code router |
route(input) returns the OnDemand targets to launch, as a list; one class, a target outside its OnDemand routes (an Always one included) or [] without an Always route → RouterDecisionError
|
FailurePolicy.Skip |
drops only the branch's own step failure — error instanceof StepFailedError (AgentFailedError, AgentAnswerInvalidError, QualityGateError, …): the branch ends BranchStatus.Skipped, its error stays in result.launches, its siblings go on and its value is absent (optional). A limit (maxSteps, maxVisits, a budget) in a Skip branch fails the launch like FailFast; a cancel of the execution stops it whole (ExecutionCancelledError). .onJoinError(StepFailedError, …) takes exactly what Skip drops: StepFailedError has its own code, step.failed
|
| In a test | script the router's decision like a decision model's, by target name: mockLlm(SupportRouter).thenReturn(decideWith({ "refund-policy-agent": 0.9 })) — also for a chat-model router (P(yes) above 0.5 is true) (Testing) |
| Pattern | Issue |
|---|---|
Quorum joins (.joinQuorum(…)), groups, arbiters — checked and typed (Parallel), run from PR 2 |
#279 |
Child workflows (.toWorkflow(W), caseOf per child finish) |
#263 |
The transition DSL's nextParallel / join, batchParallel, @QuorumRouter, parallel(…) /
optional(…) route targets, Self and nested @Workflow nodes were removed with it
(ADR-0019); rollback is on Rollback.
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)