Graphs, nodes and edges
A graph is the shape of your workflow: nodes, which are the steps, joined by edges, which say what runs next. You build it with StateGraph<TState> in C# or StateGraph::<S>::new() in Rust, and compile it once. Compiling checks the structure, so a broken graph fails at startup or in CI, not halfway through a run.
Every node receives the graph’s state and returns a new one. The state is one typed value, described on State.
A node is a name and a handler. The handler takes the state and returns the next state. It can be sync or async:
| C# | Rust | |
|---|---|---|
| Sync | AddNode(name, state => ...) |
add_node(name, |state| ...) |
| Async | AddNode(name, async (state, context, ct) => ...) |
add_async_node(name, |state, context| async move { ... }) |
An async handler also gets a NodeContext, with the thread ID, the node’s path and the attempt number, among others. In C# it also gets a CancellationToken, which is cancelled with the run or when the node’s timeout passes. A sync handler runs on the thread pool (C#) or on tokio’s blocking pool (Rust), so a slow handler doesn’t hold up other runs.
The first node you add is the entry point, where every run starts. SetEntryPoint / set_entry_point overrides it. Adding a node with a name that already exists replaces its handler.
A node with no outgoing edge ends the run. There is no special end node.
Edges and routing
Section titled “Edges and routing”An edge says which node runs after another. There are two kinds:
- A plain edge (
AddEdge/add_edge) is always taken. - A conditional edge (
AddConditionalEdge/add_conditional_edge) is taken only when its condition returnstruefor the state the node just returned.
When a node finishes, the engine tries its outgoing edges in the order you added them and follows the first that matches. A plain edge always matches, so a plain edge added after one or more conditional edges acts as the “else” branch. If a node has outgoing edges and none of them match, the run fails. Compiling can’t catch that gap, because it depends on the state at run time, so end a list of conditional edges with a plain one when no case should be left out.
This example from demo 04 branches on the order size, then joins again at FormatReceipt:
var graph = new StateGraph<OrderState>() .AddNode("CalculateSubtotal", state => state with { Subtotal = state.Quantity * state.UnitPrice }) // ... ApplyDiscount, TagBulkShipping, TagStandardShipping and FormatReceipt .SetEntryPoint("CalculateSubtotal") .AddEdge("CalculateSubtotal", "ApplyDiscount") .AddConditionalEdge("ApplyDiscount", "TagBulkShipping", state => state.Quantity >= 10) .AddEdge("ApplyDiscount", "TagStandardShipping") .AddEdge("TagBulkShipping", "FormatReceipt") .AddEdge("TagStandardShipping", "FormatReceipt") .Compile();let graph = StateGraph::<OrderState>::new() .add_node("CalculateSubtotal", |state: OrderState| OrderState { subtotal: Some(state.quantity as f64 * state.unit_price), ..state }) // ... ApplyDiscount, TagBulkShipping, TagStandardShipping and FormatReceipt .set_entry_point("CalculateSubtotal") .add_edge("CalculateSubtotal", "ApplyDiscount") .add_conditional_edge("ApplyDiscount", "TagBulkShipping", |s: &OrderState| { s.quantity >= 10 }) .add_edge("ApplyDiscount", "TagStandardShipping") .add_edge("TagBulkShipping", "FormatReceipt") .add_edge("TagStandardShipping", "FormatReceipt") .compile() .expect("graph is structurally valid");Guarded edges
Section titled “Guarded edges”A conditional edge is guarded: a condition decides whether it fires. Strata also adds guarded edges of its own for routes that only fire in one situation: a recovery policy’s fallback (on_failure), an interrupt boundary’s reject route (on_reject), and a timeout route (on_timeout).
Guards matter for loops. A loop is allowed only if at least one of its edges is guarded, because the guard is what can stop it. A loop made only of plain edges would run forever, so compiling rejects it.
Other kinds of node
Section titled “Other kinds of node”To the rest of the graph, each of these is one node, with one name and edges in and out like any other:
| Kind | C# | Rust | What it does |
|---|---|---|---|
| Fallible | AddFallibleNode |
add_fallible_node |
A node whose failures a recovery policy handles: retry, fall back, or fail. |
| Streaming | AddStreamingNode |
add_streaming_node |
A node that streams tokens while it runs. |
| Parallel group | AddParallelGroup |
add_parallel_group |
Runs several branches at once, then merges their states. |
| Subgraph | AddSubgraph |
add_subgraph |
Runs another compiled graph, with its own state type, as one step. |
| Map | AddMap |
add_map |
Runs a child graph once for each item of a list. |
| Wait | AddWaitForEvent, AddTimer |
add_wait_for_event, add_timer |
Pauses the thread until an event arrives or a time passes. |
An interrupt boundary isn’t a kind of node. It marks an existing node, so the thread pauses after that node runs. Human-in-the-loop explains it.
The branches of a parallel group aren’t graph nodes, and a map’s child graph can’t pause. See Known limitations.
Compiling and validation
Section titled “Compiling and validation”Compile() / compile() checks the graph and returns a compiled graph, which you run as many times as you like. When the structure is wrong, nothing runs. C# throws a GraphCompilationException, whose Result.Errors lists every error. Rust returns Err(StrataError::GraphInvalid(...)) with the same list. Both engines report the same codes and messages.
| Code | What it catches |
|---|---|
STRATA_ERR_MISSING_ENTRY |
The entry point names a node that doesn’t exist. This error is reported alone for its graph. |
STRATA_ERR_DANGLING_EDGE |
An edge starts or ends at a name that isn’t a node, such as a typo. |
STRATA_ERR_ORPHAN |
No path from the entry point reaches the node. |
STRATA_ERR_UNGUARDED_CYCLE |
A loop made only of plain edges. |
STRATA_ERR_EMPTY_GROUP |
A parallel group with no branches. |
STRATA_ERR_DUPLICATE_BRANCH |
Two branches of one parallel group share a name. |
STRATA_ERR_INVALID_QUORUM |
A quorum join that can never be met: no count, a count below 1, or (for a parallel group) more than its branches. |
STRATA_ERR_WAIT_IN_MAP |
A wait node inside a map’s child graph. |
STRATA_ERR_BOUNDARY_IN_MAP |
An interrupt boundary inside a map’s child graph. |
The validator also checks the graphs inside subgraphs and maps. It names their nodes by path, so an orphan Abort inside the subgraph Remediate is reported as Remediate/Abort.
Compiling makes a few checks of its own beyond these codes. For example, it refuses a recovery policy on a node that isn’t fallible, or a hop limit below 1. These fail with an exception in C# and StrataError::CompilationFailed in Rust.
Validation doesn’t check that every path can reach an end. STRATA_ERR_NO_TERMINAL isn’t built, so a graph whose guarded loop never exits still compiles. The hop limit is the guard for that case.
These checks cover the graph’s structure, and the C# or Rust compiler checks every handler against the state type. Neither can check what a model returns, so validate model output as you would any other input.
Graph definitions and CI
Section titled “Graph definitions and CI”A graph definition is a JSON file that describes a graph’s structure: its entry point, its nodes and their kinds, and every edge, with a guarded flag on each. You export one with ToDefinition() (C#, in Datalium.Strata.Storage.Schema) or to_definition() (Rust). Both engines write the same file for the same graph. The format is schema/graph-definition.schema.json.
A definition holds structure only. It has no handlers, and no condition code: an edge records that it is guarded, not what the guard checks. It also leaves out run-time settings such as node timeouts, retry counts and the hop limit.
A definition does record:
- Composite nodes. A parallel group lists its branches. A subgraph or a map holds its child graph’s whole definition.
- Interrupt boundaries. A boundary node carries an
interruptobject with its timeout, and its reject and timeout routes are guardedon_rejectandon_timeoutedges. - Node metadata. Text you attach with
SetNodeMetadata/set_node_metadata, for tools that draw or document the graph. The engine doesn’t read it. - Graph identity, when the definition comes from a compiled graph that has one.
strata graph validate --path <file or folder> runs the same validator that Compile() uses on exported files, so it returns the same errors. It exits 0 when every graph is valid, 1 for structural errors, and 2 when it can’t check a file, for example one that isn’t a graph definition or has a schema_version it doesn’t know. The CLI reference has the details.
That makes it a CI gate. Your build exports each graph from its builder, without compiling it, so a broken graph is still written out. Then strata graph validate checks the folder and fails the build.
Graph identity
Section titled “Graph identity”A thread can be paused for days. If you deploy a new version of the graph in that time, the old thread may not fit it: a node it continues at may be gone, or the state may have changed shape.
Graph identity guards against that. You give a graph an ID and an optional version when you compile it: GraphId and GraphVersion in CompilationOptions (C#), or with_graph_id and with_graph_version on CompileOptions (Rust). Every checkpoint the graph writes then records them. Threads and checkpoints explains checkpoints.
When a graph loads a thread to resume, signal, wake or end it, it compares identities first:
- A different ID is refused, before anything is written. So is a thread with an identity loaded by a graph without one.
- A different version is refused by default. A migration can accept it: a function you give (
Migrate/with_migration) that takes the stored checkpoint and returns it adapted, for example with a renamed node, or returns nothing to refuse it. Migrations are your code. Strata doesn’t migrate anything on its own. - A thread without an identity can be resumed by any graph, as it could before identities existed.
Versions are free text and compared exactly. Strata doesn’t order them.
The hop limit
Section titled “The hop limit”A guarded loop compiles, but nothing proves its guard ever turns false. In an agent loop, each pass may be a model call that costs money. The hop limit stops such a loop.
Set it when you compile: MaxNodeHops in CompilationOptions (C#), or CompileOptions::new().with_max_node_hops(...) with compile_with (Rust). There is no limit by default. You can also replace the limit for one run (RunOptions.MaxNodeHops / RunConfig::with_max_node_hops).
Each node the graph starts is one hop. A retry of the same node isn’t a new hop. A parallel group, a subgraph and a map are one hop each, and a subgraph’s child graph counts its own nodes against its own limit. The count is saved with the thread, so it carries across pauses and resumes.
When the next node would pass the limit, it doesn’t start. The run ends with HopLimitExceeded, a kind of Failed result, and Strata saves a failed checkpoint at that node.
See it run
Section titled “See it run”Each demo exists in C# and in Rust:
| Demo | What it shows | C# | Rust |
|---|---|---|---|
| 04-multi-node-graph | Plain and conditional edges, and the entry point | C# | Rust |
| 07-graph-validation | A broken graph rejected at compile, and the fix | C# | Rust |
| 14-graph-ci-gate | Exported definitions checked with strata graph validate |
C# | Rust |
| 16-loop-circuit-breaker | A loop that never converges, stopped by MaxNodeHops |
C# | Rust |
| 22-graph-identity | A new graph version refusing, then migrating, old threads | C# | Rust |
- State: the typed value each node receives and returns.
- Threads and checkpoints: how a run of a graph is saved.
- Human-in-the-loop: interrupt boundaries.