Workflows, Graphs, and Nodes¶
A Tryll agent is not a prompt. It is a graph of small, single-purpose steps that you wire together. Each user turn re-walks the graph; each step decides where to go next. This page explains why Tryll is built this way and how the pieces fit.
The building blocks¶
A graph has two things:
- A set of nodes, each with a name,
a node type —
Generate,Retrieve,ToolCall,CannedResponse, orHumanMessageGuardrail— and a set of typed params that include the node's configuration and its exit wiring. Each exit is a string field on the params object (e.g.default_exit,triggered_exit); an empty value means route to END. - A start node — the first node to run on every user turn.
Graphs are authored once, sent to the server with
CreateAgentRequest, frozen at
agent creation, and then re-walked for every turn the agent lives.
Here is the simplest useful graph — a guardrail in front of a generator, with a scripted refusal on the rejected path:
flowchart LR
guard["HumanMessageGuardrail<br>guard"]
refuse["CannedResponse<br>refuse"]
gen["Generate<br>answer"]
guard -- "triggered" --> refuse
guard -- "not_triggered" --> gen
refuse -- "default" --> END
gen -- "default" --> END
The same graph, in each client:
var graph = new TryllGraphBuilder()
.AddHumanMessageGuardrail("guard", new TryllHumanMessageGuardrailParams
{
StringStorage = "jailbreak_patterns",
TriggeredExit = "refuse", // → refuse node
NotTriggeredExit = "answer", // → answer node
})
.AddCannedResponse("refuse", new TryllCannedResponseParams
{
StringStorage = "refusal_lines",
// DefaultExit is "" (END) by default
})
.AddGenerate("answer", new TryllGenerateParams())
.SetStartNode("guard")
.SetDefaultModelName("My Local Model")
.Build();
auto* GuardParams = NewObject<UTryllHumanMessageGuardrailParams>();
GuardParams->StringStorage = TEXT("jailbreak_patterns");
GuardParams->TriggeredExit = TEXT("refuse");
GuardParams->NotTriggeredExit = TEXT("answer");
auto* RefuseParams = NewObject<UTryllCannedResponseParams>();
RefuseParams->StringStorage = TEXT("refusal_lines");
// RefuseParams->DefaultExit defaults to "" (END)
FTryllGraphDescription Graph = FTryllGraphBuilder()
.AddNode(TEXT("guard"), GuardParams)
.AddNode(TEXT("refuse"), RefuseParams)
.AddNode(TEXT("answer"), NewObject<UTryllGenerateParams>())
.SetStartNode(TEXT("guard"))
.SetDefaultModelName(TEXT("My Local Model"))
.Build();
namespace TC = Tryll::Client;
TC::HumanMessageGuardrailParams guardParams;
guardParams.string_storage = "jailbreak_patterns";
guardParams.triggered_exit = "refuse";
guardParams.not_triggered_exit = "answer";
TC::CannedResponseParams refuseParams;
refuseParams.string_storage = "refusal_lines";
// refuseParams.default_exit defaults to "" (END)
TC::GraphDescription graph;
graph.AddNode("guard", guardParams)
.AddNode("refuse", refuseParams)
.AddNode("answer", TC::GenerateParams{})
.SetStartNode("guard")
.SetDefaultModelName("My Local Model");
from tryll_client import GraphDescription
from tryll_client._generated.nodes import (
HumanMessageGuardrailParams, CannedResponseParams, GenerateParams,
)
guard_params = HumanMessageGuardrailParams(
string_storage = "jailbreak_patterns",
triggered_exit = "refuse",
not_triggered_exit = "answer",
)
refuse_params = CannedResponseParams(
string_storage = "refusal_lines",
# default_exit defaults to "" (END)
)
graph = (
GraphDescription()
.add_node("guard", guard_params)
.add_node("refuse", refuse_params)
.add_node("answer", GenerateParams())
.set_start_node("guard")
.set_default_model_name("My Local Model")
)
Exit fields: how nodes "talk" to each other¶
Nodes do not call each other. They return a named exit, and the agent follows the matching edge. Every node type declares its possible exits as typed string fields on its params object — see the table in Workflow Nodes.
For example:
| Node type | Exit fields on params |
|---|---|
Generate |
default_exit |
Retrieve |
found_exit, not_found_exit |
ToolCall |
tool_called_exit, no_tool_called_exit |
CannedResponse |
default_exit |
HumanMessageGuardrail |
triggered_exit, not_triggered_exit |
An empty string means route to END; this is the field's default
value. A non-empty string must name another node in the same graph —
the server rejects graphs where an exit field names a node that doesn't
exist (3008 InvalidExitTarget).
Because exits are fields on the params object, exits that terminate the turn (route to END) can simply be left at their default — you only need to set the fields that route somewhere other than END.
The per-turn loop¶
When a SendMessage arrives, the agent:
- Appends a new interaction to its dialog, starting with the user's message.
- Walks the graph from
start_at, one node at a time. Each node: - reads from the current interaction (and anything earlier nodes attached to it),
- does its work (retrieve from an index, run the model, match a pattern, pick a canned line, …),
- optionally attaches new components to the interaction, and
- returns its chosen exit.
- Follows the route for that exit to the next node.
- Stops when the route target is
"END", when the step budget is exceeded, or when the turn is cancelled.
What each node attaches along the way is what makes the dialog
itself useful state. A Retrieve node attaches retrieved knowledge
to the current turn, and a downstream Generate node's
projection picks that knowledge up
and folds it into the prompt.
Why a graph instead of a prompt template¶
Three reasons:
1. Composition. "Retrieve, then generate" is different from "check for jailbreak first, then retrieve, then generate". Prompt templates push this branching into strings; a graph makes it executable and inspectable. Every node is typed and diagnostics-friendly.
2. Cost control. The graph lets you short-circuit expensive work.
A guardrail that
routes to a canned response
never touches the model. A
tool-call node with
generate_on_no_tool=false (experimental) does zero extra generation on a miss.
3. Reuse. Graphs are data. You can build them at runtime from C++,
Python, or Unreal, ship them in a
UTryllWorkflowAsset,
or construct them dynamically from user configuration. The server
does not know or care where the graph came from.
Turns are serial; graphs are stateless between turns¶
An agent runs one turn at a time. If a second SendMessage arrives
while the previous turn is still running, it is rejected with
error 3001; the in-flight
turn keeps running. Cancellation is explicit
(DestroyAgent, socket close), not pre-emption.
The graph itself carries no per-turn state. All state lives on the agent's dialog (the accumulated interactions) and in the per-node KV caches. This is why the same graph description can be replayed cleanly every turn — the work of "what has been said" is separate from the work of "what to do next".
Edges and pitfalls¶
- Routing loops. Nothing stops you from routing node A → node A;
the agent has a configurable
max_steps_per_turnbudget (default 64) that breaks infinite loops by aborting the turn with an error. - Exits you "know" will never fire still need a value. Leave them at the default empty string (routes to END). The server validates that no exit field names a missing node, but it does not enforce that every exit field is reachable.
- Model sharing is cross-graph, not cross-node. Two
Generatenodes in one graph that use the same model share the underlying weights (good) but have independent KV caches (necessary — they see different prompts). Budget token-count accordingly; see Projection and Token Budgets. - The start node runs every turn. If your start node is
Generate, it will generate every turn, even if a later node would have short-circuited. Put guardrails and retrieval before the model, not after, if you want them to actually gate work.