Branch Node¶
NodeType = Branch (wire ordinal 14)
A model-free routing node. It runs no inference, writes no slot, and never
emits anything on the wire — the same cost class as Pause and
Transform. It picks one of seven tests, evaluates it
against a single operand (a turn-local slot or an agent variable), and takes
then_exit or else_exit.
Use it to route on a value produced this turn — generated text, a
Transform result, or a canned seed — which no other node can inspect (see
Slots and inter-node value passing).
Agent variables are the other operand under Expression, not the subject:
the model proposes a value, live game state judges it (for example, checking
a generated action against a currently-allowed set). If the whole predicate
is over variables the game already knows the answer before the turn starts —
prefer a different graph, ChangeAgentParam, or Pause + Resume(resume_node)
in that case. See docs/research/workflow/conditional-routing-if-and-switch-nodes.md
for the design rationale.
Parameters¶
| Param | Type | Default | Range | Structural | Description |
|---|---|---|---|---|---|
test |
BranchTest | BranchTest.NonEmpty | — | — | Which predicate to run. Changing this recompiles the active condition. |
input |
Optional[str] | empty -> "user_message" | — | ✓ | Slot discriminant. Empty = "user_message". Mutually exclusive with a non-empty variable. Unused under test=Expression. |
variable |
Optional[str] | inherit model default | — | ✓ | Variable discriminant (name of a declared agent variable). Mutually exclusive with a non-empty input. Unused under test=Expression. |
values |
Optional[Any] | inherit model default | — | — | Candidate literals for Equals / EqualsIgnoreCase / Contains / Regex. Match = any element matches. Unused under NonEmpty / IsTrue / Expression. Mutable — the game can remap cases between turns without recreating the agent. |
condition |
Optional[str] (multiline) | inherit model default | — | — | JSON filter condition used when test=Expression. Supports the retrieve filter grammar plus {"slot":"<name>"} operands. Unused under other tests. |
notify_client |
bool | False | — | — | When true, fire OnNodeEvent("branch_taken", …) after routing. |
Exits¶
Each exit is a structural string field on the node's params; its value names the target node (empty = END).
| Exit | Param field | Description |
|---|---|---|
then |
then_exit |
Exit taken when the test matches (or Expression is true). Empty = END. |
else |
else_exit |
Exit taken when the test does not match, Expression is false, or the operand is missing. Empty = END. |
Test modes¶
input and variable are mutually exclusive — set at most one; an empty
input (the default) resolves to user_message. Every test but
Expression reads that single resolved operand; Expression instead
evaluates condition against slots and variables directly. Fields the
active test doesn't use are ignored (logged at debug, never an error) —
this is deliberate: input/variable are structural, so a mutation that
flips test cannot also clear them.
| Test | Reads | Needs | Trim / case |
|---|---|---|---|
NonEmpty (default) |
resolved operand | nothing else | Operand trimmed of ASCII whitespace before the check; a set<string> variable checks element count instead of text |
IsTrue |
variable only |
a declared bool variable — rejected at Create on a slot operand or a non-bool variable |
n/a (boolean value, not text) |
Equals |
resolved operand | values (≥ 1) |
Operand trimmed; byte-exact comparison against each values element. values entries themselves are never trimmed — a trailing space typed into one can never match |
EqualsIgnoreCase |
resolved operand | values (≥ 1) |
Same as Equals, both sides ASCII-folded |
Contains |
resolved operand | values (≥ 1) |
Operand trimmed; substring match against each values element, case-sensitive |
Regex |
resolved operand | values (≥ 1, each a regex pattern) |
Operand trimmed; std::regex_search — matches anywhere in the operand, not a full-string match; case-sensitive, diverging from RegexGuardrail's hardcoded case-insensitive matching, because Equals vs EqualsIgnoreCase already makes case explicit in the test name |
Expression |
slots/variables named inside condition |
non-empty condition |
The filter grammar's own operand rules apply per-operand; trim for a {"slot":…} operand happens in Branch's slot-binding step (not in the shared evaluator), so a trailing space in a rendered slot never blocks a match. See Filter grammar |
lt/le/gt/ge inside an Expression condition never accept a
{"slot":…} operand — slots type as String and ordering requires a
numeric type on both sides.
NonEmpty on an Int/Float/Bool variable, and a text test
(Equals/EqualsIgnoreCase/Contains/Regex) on a StringSet variable,
are rejected at Create — the former is a constant predicate (dead exit),
the latter should use Expression's in operator against the set instead.
Exit routes¶
| Exit | Condition |
|---|---|
then |
The active test matched (or Expression evaluated true). |
else |
The test did not match, Expression evaluated false or Undefined, or a simple-mode operand was missing. |
Simple-mode missing operand → else, always, and it short-circuits before
the test runs. A slot the graph never wrote on this path, or a variable
that somehow resolves to nothing, counts as missing. Without the
short-circuit, test=Equals, values=[""] would route a missing slot to
then (find("") == 0); the short-circuit makes that impossible. This is
logged at debug, not warn — for a routing node this is the common case,
not an anomaly.
Expression missing slot → equation is Undefined → else + warning.
A {"slot":…} the graph never wrote makes the filter result Undefined
(Kleene three-valued logic — see
Filter grammar).
Branch treats Undefined like false for exit selection (else), but
logs a warn and sets diagnostics operand_missing=true. Prefer a positive
in(slot, allowed) with then = allowed / else = blocked: a missing
slot then fails closed to blocked. Do not rely on not(eq(...)) /
not(in(...)) to treat missing as a match — those stay Undefined too.
A slot that was written but is blank ("") is present, not missing —
producing nodes still write their slot even when generation or rendering
failed, so "absent" almost always means that node didn't run on this
path, not "it ran and produced nothing".
Branch can never fail a turn: regexes and the Expression condition are
pre-validated at Create/mutation time, so evaluation itself is
noexcept.
Side effects¶
None. Branch is reader-only: it does not write a slot, call a model, or
emit answer text. Set notify_client = true to observe routing mid-turn
via NodeEvent("branch_taken", {exit, test, operand_kind, matched_value})
— the payload deliberately omits the operand's raw text, since NodeEvent
has no diagnostics content-trait gating.
Examples¶
A refusal-fallback gate: a hidden classification Generate produces a
one-word verdict, and Branch routes "UNSAFE" to a canned refusal while
everything else reaches the real answer.
from tryll_client.graph import (
GraphDescription, GenerateParams, BranchParams, BranchTest,
CannedResponseParams, SamplingOverrides, SendAnswer, HistoryRole,
)
graph = (
GraphDescription()
.add_node("classify", GenerateParams(
send=SendAnswer.None_, history_role=HistoryRole.None_, output_name="verdict",
system_prompt="Answer with exactly one word: SAFE or UNSAFE.",
sampling=SamplingOverrides(temperature=0.0, seed=42),
default_exit="gate",
))
.add_node("gate", BranchParams(
test=BranchTest.Contains, input="verdict", values=["UNSAFE"],
then_exit="refuse", else_exit="answer",
))
.add_node("refuse", CannedResponseParams(
inline_strings=["I'd rather not go there."], send=SendAnswer.Whole, default_exit="",
))
.add_node("answer", GenerateParams(send=SendAnswer.Streamed, default_exit=""))
.set_start_node("classify")
)
agent = client.create_agent(graph)
# Remap the trigger word at runtime — `values` is mutable.
# Structural fields must still match create-time values.
agent.change_params("gate", BranchParams(
test=BranchTest.Contains, input="verdict", values=["SAFE"],
then_exit="refuse", else_exit="answer",
))
using namespace Tryll::Client;
using namespace Tryll::NodeParams;
GenerateParamsT classifyP;
classifyP.send = Tryll::SendAnswer_None;
classifyP.history_role = Tryll::HistoryRole_None;
classifyP.output_name = "verdict";
classifyP.system_prompt = "Answer with exactly one word: SAFE or UNSAFE.";
classifyP.sampling = std::make_unique<SamplingOverridesT>();
classifyP.sampling->temperature = 0.0f;
classifyP.sampling->seed = 42;
classifyP.default_exit = "gate";
BranchParamsT gateP;
gateP.test = Tryll::BranchTest_Contains;
gateP.input = "verdict";
gateP.values = { "UNSAFE" };
gateP.then_exit = "refuse";
gateP.else_exit = "answer";
// Keep a caller-owned baseline before moving into the graph — AddBranch
// takes ownership, so reading gateP after the move is undefined.
BranchParamsT gateBaseline = gateP;
CannedResponseParamsT refuseP;
refuseP.inline_strings = { "I'd rather not go there." };
refuseP.send = Tryll::SendAnswer_Whole;
// default_exit left empty = END
GenerateParamsT answerP;
answerP.send = Tryll::SendAnswer_Streamed;
// default_exit left empty = END
GraphDescription graph;
graph.AddGenerate("classify", std::move(classifyP))
.AddBranch("gate", std::move(gateP))
.AddCannedResponse("refuse", std::move(refuseP))
.AddGenerate("answer", std::move(answerP))
.SetStartNode("classify")
.SetDefaultModelName("My Local Model");
auto agent = client.CreateAgent(graph);
// Remap the trigger word at runtime — `values` is mutable. Use the
// Async form from a reader-thread callback to avoid deadlocking.
BranchParamsT newGateP = gateBaseline;
newGateP.values = { "SAFE" };
agent.ChangeParamsAsync("gate", std::move(newGateP));
#include "Generated/TryllGraphBuilder.Nodes.h"
#include "Generated/TryllNodeParamsFactory.h"
UTryllGenerateParams* ClassifyP = UTryllNodeParamsFactory::MakeGenerateParams(this);
ClassifyP->Send = ETryllSendAnswer::None;
ClassifyP->HistoryRole = ETryllHistoryRole::None;
ClassifyP->OutputName = TEXT("verdict");
ClassifyP->SystemPrompt = TEXT("Answer with exactly one word: SAFE or UNSAFE.");
ClassifyP->DefaultExit = TEXT("gate");
UTryllBranchParams* GateP = UTryllNodeParamsFactory::MakeBranchParams(this);
GateP->Test = ETryllBranchTest::Contains;
GateP->Input = TEXT("verdict");
GateP->Values = { TEXT("UNSAFE") };
GateP->ThenExit = TEXT("refuse");
GateP->ElseExit = TEXT("answer");
UTryllCannedResponseParams* RefuseP = UTryllNodeParamsFactory::MakeCannedResponseParams(this);
RefuseP->InlineStrings = { TEXT("I'd rather not go there.") };
RefuseP->Send = ETryllSendAnswer::Whole;
FTryllGraphDescription Graph = FTryllGraphBuilder()
.AddNode(TEXT("classify"), ClassifyP)
.AddNode(TEXT("gate"), GateP)
.AddNode(TEXT("refuse"), RefuseP)
.AddNode(TEXT("answer"), UTryllNodeParamsFactory::MakeGenerateParams(this))
.SetStartNode(TEXT("classify"))
.SetDefaultModelName(TEXT("My Local Model"))
.Build();
See the test-chat branch workflow
(test-chat/src/workflows/BranchWorkflow.cpp) for the runnable version —
/agent branch — and the QA scenarios scenarios.protocol.branch_routing
(the Contains example above) and scenarios.protocol.branch_expression
(the Expression / allowlist form, gating a generated action against a
live set<string> variable).
Related¶
- Filter grammar — the JSON condition grammar used under
test=Expression, including the{"slot":…}operand - Concept: Slots and inter-node value passing
- Concept: Agent Variables
- Transform node, Pause node, Regex Guardrail node
- Research:
docs/research/workflow/conditional-routing-if-and-switch-nodes.md(design rationale)