Routing¶
Conditional flow inside a Plan: when one step's output decides
which step runs next. Two forms — routes={...} for code-decided
branches, routes_by="field" for LLM-decided branches — with the
same call-site visibility and the same compile-time validation.
Signature¶
from lazybridge import Step, when
# Form A — predicate map. Your code decides.
Step(
target,
name="...",
routes={
"target_step_name": predicate, # Callable[[Envelope], bool]
"another_target": another_predicate,
},
after_branches=None, # str — optional rejoin point
)
# Form B — Literal field. The LLM decides.
Step(
target,
name="...",
output=SomeModel, # must declare a Literal[...] field
routes_by="field_name", # name of that Literal field
after_branches=None,
)
routes and routes_by are mutually exclusive — use one (or
neither) per step. Both place the routing decision at the call
site so reviewers can see the branch table without reading any
Pydantic class.
The when DSL¶
routes predicates are callables (Envelope) -> bool. Writing them
as raw lambdas works but is dense; the when DSL makes the common
shapes declarative.
from lazybridge import when
# Field-level checks (the workhorse)
when.field("items").empty() # field is None or zero-length container
when.field("items").not_empty()
when.field("severity").equals("urgent") # ==
when.field("severity").not_equals("spam")
when.field("approved").is_(True) # `is` — for True / False / None
when.field("kind").in_({"a", "b"}) # membership
when.field("kind").not_in_({"a", "b"})
when.field("score").greater_than(0.5)
when.field("score").less_than(0.5)
when.field("text").matches(r"^urgent") # re.search
# Strict mode — typos in field names raise instead of silently routing wrong
when.field("items", strict=True).empty()
# Escape hatches
when.payload(callable) # callable(payload) -> bool
when.envelope(callable) # callable(envelope) -> bool
when.errored() # True iff envelope carries an error
Synopsis¶
Routing in Plan is declared on the Step, never hidden
inside the output model. Two forms:
Form A — routes={...} (predicate map). Your code decides the
branch. The framework calls each predicate in declared order with
the step's output envelope; the first one returning True
makes Plan jump to that target step. If none returns True,
linear progression continues to the next declared step.
Form B — routes_by="field" (Literal field). The LLM decides
the branch. You declare a Literal[...]-typed field on the step's
output=Model; the framework reads env.payload.<field> after the
step runs. If the value is a string matching a step name, Plan
jumps there; if None or unmatched, linear progression continues.
Both forms are validated at construction. Unknown route targets,
malformed Literal types, predicates that aren't callable — all
caught by PlanCompileError before any LLM call.
Detour vs. exclusive branch¶
By default, routing is a detour: when step A routes to step X, Plan runs X and then resumes linear progression from X's declared position. There is no implicit "no fall-through" mode.
Set after_branches="step_name" alongside routes or routes_by
to make routing exclusive: only the matched branch runs; all
declared steps between the routing step and the rejoin point are
skipped; execution continues at step_name after the chosen branch
completes.
| Pattern | When to use | Resume behaviour |
|---|---|---|
| Exclusive branch with rejoin (default for forward routing) | Predicates cover all outcomes; after_branches="<rejoin>" set |
Routed branch runs, then jumps to <rejoin>; sibling branches are skipped |
| Skip optional middle step | Single forward predicate, routed-to step is the last declared and serves as the rejoin for the non-routed path too | Routed: jumps over optional steps to the last step. Non-routed: linear, runs intermediate steps, then the last step. Both end at the same place. |
| Loop / self-correction | Backwards route to an earlier step; no after_branches= |
Route fires → run earlier step → linear walks forward from there until the predicate stops firing. max_iterations is the safety net. |
Don't use a single forward predicate without a rejoin
routes={"X": predicate} with no after_branches= and X not at
the end of the declared list is always a bug. When the predicate
doesn't fire, control falls through linearly and runs every step
between the routing step and the end — including X itself. Either
cover every outcome with explicit predicates plus after_branches=,
or use the "Skip optional middle step" pattern where X is genuinely
the last step.
When to use which form¶
routes={...}when your code decides — programmatic checks on the typed payload (empty list, score below threshold, regex match, multi-field combinator). Cheap, deterministic, no extra LLM call.routes_by="field"when the LLM decides — classification, triage, intent detection. The model emits a Literal value as part of its structured output; you don't write the dispatch logic.
When NOT to use routing¶
- Linear pipelines. Just stack
Steps in declared order; no routing fields needed. - Parallel fan-out where every branch should run. Use
Step(parallel=True)on each branch; no routing primitives are involved. - Crash recovery. That's
resume=True+checkpoint_key=, not routing. Routing is control flow within a single run; resume is durability across runs. - Loop counters. Routing back to an earlier step works for
bounded retries via
max_iterations, but if you need an explicit attempt counter, persist it viawrites=and read it via a sentinel rather than relying on routing alone.
Example¶
from typing import Literal
from pydantic import BaseModel
from lazybridge import Agent, LLMEngine, Plan, Step, Store, from_prev, from_start, from_step, when
# 1) Form A (predicate map) — empty-search early-out via the when DSL.
#
# The routes table covers BOTH outcomes (when.empty() AND when.not_empty())
# so linear fall-through never fires. after_branches="log_outcome"
# guarantees every chosen branch ends at the same rejoin point. For
# multi-step branches (e.g. rank → write), wrap them in a nested Plan
# and pass it as a single Step's target — see the routing.md "Detour vs.
# exclusive branch" section.
class Hits(BaseModel):
items: list[str]
searcher = Agent(engine=LLMEngine("gpt-5.4-mini"), name="search", output=Hits)
writer = Agent(engine=LLMEngine("gpt-5.4-mini"), name="write")
apology_agent = Agent(engine=LLMEngine("gpt-5.4-mini"), name="apology")
log_outcome = Agent(engine=LLMEngine("gpt-5.4-mini"), name="log_outcome")
plan = Agent(
engine=Plan(
Step("search",
output=Hits,
routes={
"apology": when.field("items").empty(), # empty hits
"write": when.field("items").not_empty(), # has hits
},
after_branches="log_outcome"), # every branch lands here
Step("write", task="Write a 200-word brief from the search hits."),
Step("apology", task="Apologise; suggest broader terms."),
Step("log_outcome", task="Emit one line of metrics for this run."),
),
tools=[searcher, writer, apology_agent, log_outcome],
)
# Execution shapes (verified against _routing()):
# items=[] (empty): search → apology → log_outcome
# items=[...] (some): search → write → log_outcome
# 2) Form B (routes_by) — LLM-decided triage with exclusive branching.
class Triage(BaseModel):
summary: str
severity: Literal["urgent", "normal", "spam"] | None = None
classifier = Agent(engine=LLMEngine("gpt-5.4-mini"), name="classify", output=Triage)
escalator = Agent(engine=LLMEngine("gpt-5.4-mini"), name="urgent")
triager = Agent(engine=LLMEngine("gpt-5.4-mini"), name="normal")
closer = Agent(engine=LLMEngine("gpt-5.4-mini"), name="spam")
archiver = Agent(engine=LLMEngine("gpt-5.4-mini"), name="archive")
plan = Agent(
engine=Plan(
Step("classify",
output=Triage,
routes_by="severity", # reads env.payload.severity
after_branches="archive"), # skip siblings; rejoin at "archive"
Step("urgent", task="Page on-call; open P0."),
Step("normal", task="Add to support backlog."),
Step("spam", task="Close as spam."),
Step("archive", task="Log to the audit archive."), # always runs
),
tools=[classifier, escalator, triager, closer, archiver],
)
# 3) Self-correction loop — route back when the reviewer rejects.
class Verdict(BaseModel):
feedback: str
approved: bool
reviewer = Agent(engine=LLMEngine("gpt-5.4-mini"), name="review")
publisher = Agent(engine=LLMEngine("gpt-5.4-mini"), name="publish")
store = Store()
plan = Agent(
engine=Plan(
Step("write",
task="Draft a 200-word answer; if a 'verdict' is in the store, "
"rewrite addressing the feedback.",
context=from_start,
sources=[store],
writes="draft"),
Step("review",
task="Score the draft; approved=True only if accuracy + tone + length all pass.",
context=from_prev,
output=Verdict,
writes="verdict",
# Loop back to the writer when rejected.
routes={"write": when.field("approved").is_(False)}),
Step("publish", task="Final-format and publish.", context=from_step("write")),
# Without max_iterations, an infinite-rejection bug would loop
# forever. 8 attempts is a defensible upper bound for most
# policies; tune to your SLA.
max_iterations=8,
),
tools=[writer, reviewer, publisher],
store=store,
)
# 4) Lambda escape hatch for one-off predicates — still covers BOTH branches
# and uses after_branches. Lambdas don't change the safety contract.
plan = Agent(
engine=Plan(
Step("classify",
output=Hits,
routes={
"apology": lambda env: not env.payload.items,
"rank": lambda env: bool(env.payload.items),
},
after_branches="log_outcome"),
Step("rank", task="Rank the search hits."),
Step("apology", task="Apologise; suggest broader terms."),
Step("log_outcome", task="Emit metrics."),
),
tools=[searcher, writer, apology_agent, log_outcome],
)
# 5) Custom predicate function for multi-field combinators — paired with its
# explicit complement so coverage is exhaustive. Skip-on-no-match is a
# bug, not a shorthand.
class Score(BaseModel):
score: float
topic: str
def needs_review(env) -> bool:
"""Route to review when the score is low AND the topic is sensitive."""
return env.payload.score < 0.5 and env.payload.topic in {"medical", "legal"}
def safe_to_auto(env) -> bool:
"""Catch-all complement of needs_review."""
return not needs_review(env)
score_classifier = Agent(engine=LLMEngine("gpt-5.4-mini"), output=Score, name="classify")
auto_agent = Agent(engine=LLMEngine("gpt-5.4-mini"), name="auto")
review_agent = Agent(engine=LLMEngine("gpt-5.4-mini"), name="review")
audit_agent = Agent(engine=LLMEngine("gpt-5.4-mini"), name="audit")
plan = Agent(
engine=Plan(
Step("classify",
routes={
"review": needs_review,
"auto": safe_to_auto,
},
after_branches="audit"),
Step("auto"),
Step("review"),
Step("audit"), # rejoin terminal — always runs
),
tools=[score_classifier, auto_agent, review_agent, audit_agent],
)
Pitfalls¶
- The detour trap — single-predicate forward routing. The
single most common bug.
routes={"X": predicate}only fires when the predicate is True. When it's False, the Plan falls through linearly and runs every declared step between the routing step and the end — including the step you thought was reachable only via routing. Symptoms: the "branch-only" step runs even on the success path, and the final output comes from the wrong step. Fixes (in order of preference): (1) cover every outcome explicitly with multiple predicates plusafter_branches="<rejoin>"; (2) useroutes_by="field"with an exhaustiveLiteral[...]output; (3) use the "skip optional middle step" pattern where the routed-to step is genuinely the last in declared order. routes_byrequiresoutput=to be a Pydantic model with the named field asLiteral[...](orLiteral[...] | None). Anything else fails at construction. The compiler also verifies every Literal value matches a declared step name.- Routing cycles are not a compile error.
A → B → Amay be intentional (self-correction loops). They surface at runtime asMaxIterationsExceededoncePlan(max_iterations=...)fires. Always pair a loop with a counter or termination predicate. - Predicate evaluation order matters.
routes={...}evaluates in declared order; the firstTruewins. If multiple predicates can match, put the more specific one first. routes_byandroutesare mutually exclusive. Setting both on the same step fails at construction.routes_by="field"returningNone(or any value not matching a step name) means "don't route" — linear progression continues. This is not an error; design your Literal type accordingly.when.field(name)is non-strict by default. A typo in the field name silently returnsNone, which can mask routing bugs (e.g. a payload that always routes to the empty branch because the predicate sees nothing). Usewhen.field(name, strict=True)to make typos raise.whenchains return predicates, not bools.when.field("x").empty()is a callable;Stepinvokes it later. Don't accidentally call it eagerly (when.field("x").empty()(env)works but is rarely what you mean to write).- Routing primitives are ignored on parallel branches. A
parallel=Truestep'sroutes=/routes_by=is silently dropped — parallel bands have their own control flow. Set routing on the step after the band. - A predicate that raises is wrapped as
PlanRuntimeError. The engine catches the underlying exception and re-raises it asPlanRuntimeError(aRuntimeErrorsubclass) with the offending step name, target, and underlying error class in the message. Distinct fromPlanCompileError(build-time DAG validation) so caught-at-runtime predicate bugs don't conflate with caught-at-construction DAG bugs. after_branchesmust come AFTER the routing step in declared order. A typo or a backward reference fails fast at construction with aPlanCompileErrormessage that names both positions. The rejoin point is also validated for existence.
See also¶
- Plan — the engine that interprets routing decisions.
- Step — the surface that carries
routes=,routes_by=, andafter_branches=. - Sentinels — sentinels are about data flow; routing is about control flow. They don't overlap.
- verify= — judge-and-retry around an output; complementary to routing for "wrong → try again" semantics.
- Guides → Full → Parallel plan steps (Phase 3b) — concurrent bands have their own control flow; routing primitives don't apply.