Skip to content
These docs cover Strata 0.9.0-preview.1.It is a preview, and the API can still change before 1.0.

Compose subgraphs

A subgraph is a compiled graph that runs as one node of another graph. It keeps its own state type, its own edges and its own recovery policies. Two functions connect it to the parent: one builds the child’s starting state from the parent’s, and one folds the child’s final state back in.

In this guide you build the support desk from demo 13. A Triage node routes each ticket to one of two agents, and each agent is a graph of its own:

  • BillingAgent works on a BillingCase.
  • TechAgent works on a TechCase. Its first step, Diagnose, is another subgraph over a Probe, so the desk is three graphs deep.

The desk’s Ticket state knows nothing about the other three state types.

You need the Strata packages or crates in a project. See Installation. This guide assumes you know how to build and run a graph (Graphs, nodes and edges) and how recovery policies work.

The full demo is 13-subgraph-composition in C# and 13-subgraph-composition in Rust.

A child is an ordinary graph. Build it and compile it on its own. Compiling validates it, so a broken child fails here, before any parent uses it.

This is the diagnostics graph, over its own Probe state:

static CompiledGraph<Probe> BuildDiagnostics(Device device, Counter? networkChecks) =>
new StateGraph<Probe>()
.AddNode("CheckNetwork", probe =>
{
networkChecks?.Increment();
return probe with { Findings = [.. probe.Findings, "network ok"] };
})
.AddFallibleNode("CheckFirmware", probe => device switch
{
Device.Healthy => probe with { Findings = [.. probe.Findings, "firmware 2.1 ok"] },
_ => throw NodeException.Fatal("firmware 1.0 is too old to diagnose remotely"),
})
.AddNode("Summarize", probe => probe with
{
Findings = [.. probe.Findings, $"{probe.Findings.Count} checks passed"],
})
.AddEdge("CheckNetwork", "CheckFirmware")
.AddEdge("CheckFirmware", "Summarize")
.Compile();
sealed record Probe(IReadOnlyList<string> Findings);

The child’s state must be serializable, like any graph’s state. In Rust it needs Clone + Send + Sync + Serialize + DeserializeOwned + 'static.

Call AddSubgraph (C#) or add_subgraph (Rust) on the parent with four arguments:

Argument C# Rust
The node’s name string impl Into<String>
The compiled child CompiledGraph<TChild> impl Into<Arc<CompiledGraph<T>>>
input: parent state to the child’s starting state Func<TState, TChild> Fn(&S) -> T
output: fold the child’s result back Func<TState, TChild, TState> Fn(&S, T) -> S

output receives the state the node started with as well as the child’s final state, so it can keep fields that only the parent has. In C#, AddSubgraph also takes optional node metadata. In Rust, use set_node_metadata.

TechAgent adds the diagnostics graph as its Diagnose node. The child starts from an empty Probe, and its findings become steps in the TechCase:

static CompiledGraph<TechCase> BuildTechAgent(Device device, Counter? networkChecks = null) =>
new StateGraph<TechCase>()
.AddSubgraph(
"Diagnose",
BuildDiagnostics(device, networkChecks),
_ => new Probe([]),
(tech, probe) => tech with
{
Steps = [.. tech.Steps, .. probe.Findings.Select(finding => $"diagnostics: {finding}")],
})
.AddNode("ApplyFix", tech => /* ... */)
.AddNode("DraftReply", tech => /* ... */)
.AddConditionalEdge("ApplyFix", "ApplyFix", tech => !tech.Fixed && tech.Attempts < 3)
.AddEdge("ApplyFix", "DraftReply")
.AddEdge("Diagnose", "ApplyFix")
.Compile();

To the rest of the parent, Diagnose is one ordinary node. It has edges in and out, and it can carry a recovery policy or be an interrupt boundary. The ApplyFix loop inside TechAgent is invisible to whatever graph uses TechAgent.

One compiled child can back several nodes. A compiled graph holds no per-run state. In Rust, pass an Arc<CompiledGraph<T>> to share it; a plain CompiledGraph<T> is moved in.

When the child uses the parent’s state type

Section titled “When the child uses the parent’s state type”

There is no separate method for a child over the same state type. Pass identity mappers: the child starts from the parent’s state, and its result replaces it.

var work = new StateGraph<Job>()
.AddNode("Double", job => job with { N = job.N * 2, Log = [.. job.Log, "double"] })
.Compile();
var graph = new StateGraph<Job>()
.AddNode("Prepare", job => job with { Log = [.. job.Log, "prepare"] })
.AddSubgraph("Work", work, job => job, (_, result) => result)
.AddEdge("Prepare", "Work")
.Compile();
sealed record Job(int N, IReadOnlyList<string> Log);

Run with N = 5, it ends with N = 10, log = [prepare, double].

A supervisor that picks an agent is ordinary conditional edges, with subgraph nodes as their targets. The desk adds both agents and routes on the category that Triage sets:

var graph = new StateGraph<Ticket>()
.AddNode("Triage", ticket => /* ... sets Category to "billing", "tech" or "general" */)
.AddSubgraph(
"BillingAgent",
billing,
_ => BillingCase.New(),
(ticket, result) => ticket with
{
Reply = result.Reply,
Log = [.. ticket.Log, .. result.Steps.Select(step => $"billing: {step}")],
})
.AddSubgraph(
"TechAgent",
tech,
_ => TechCase.New(),
(ticket, result) => ticket with
{
Reply = result.Reply,
Log = [.. ticket.Log, .. result.Steps.Select(step => $"tech: {step}")],
})
// ... GeneralReply and SendReply
.AddConditionalEdge("Triage", "BillingAgent", ticket => ticket.Category == "billing")
.AddConditionalEdge("Triage", "TechAgent", ticket => ticket.Category == "tech")
.AddEdge("Triage", "GeneralReply")
.AddEdge("BillingAgent", "SendReply")
.AddEdge("TechAgent", "SendReply")
.AddEdge("GeneralReply", "SendReply");

TechAgent contains Diagnose, so nesting needs nothing extra. Nesting has no depth limit.

The set of children is fixed when you build the parent. To run a child once for each item of a list, use a map instead: see Fan out over a list.

You run the desk like any graph. A tech ticket goes through all three levels. The demo prints the desk’s log:

=== Scenario 2: a tech ticket -- TechAgent loops, and nests a subgraph ===
Reply: Fixed after 2 attempt(s).
Log:
- triage: tech
- tech: diagnostics: network ok
- tech: diagnostics: firmware 2.1 ok
- tech: diagnostics: 2 checks passed
- tech: fix attempt 1: no change
- tech: fix attempt 2: resolved
- tech: drafted reply
- reply sent

Inside a child, node paths are qualified by the subgraph node: TechAgent/Diagnose/CheckFirmware. You see these paths in NodeContext.NodePath / ctx.node_path(), in run events, in the names of streamed chunks, in WaitingAt for a paused or failed thread, and in strata graph validate, which reports an orphan Abort inside Remediate as Remediate/Abort.

A child handles its own failures first, with its own recovery policies. In scenario 3 of the demo, the billing child retries a timed-out API call twice and succeeds. The desk never sees the failure.

When the child gives up, the subgraph node fails. The failure is transient if the child’s retries ran out on a transient error, and fatal otherwise. The policy you set on the subgraph node then treats the whole child as one node:

var recovery = RecoveryPolicy.Retry(policy.Retries);
if (policy.Fallback)
{
graph
.AddNode("Escalate", ticket => ticket with { /* ... */ })
.AddEdge("Escalate", "SendReply");
recovery = recovery.WithFallback("Escalate");
}
return graph
.SetRecoveryPolicy("BillingAgent", recovery)
.SetRecoveryPolicy("TechAgent", recovery)
.Compile();
  • A retry re-runs the whole child from its entry point, so make the child safe to repeat.
  • A fallback runs on the parent’s state from before the subgraph node started. None of the child’s partial work reaches it.
  • With neither, the parent saves a Failed checkpoint at the subgraph node.

The run returns NodeFailed for the subgraph node, and its message names every level. In scenario 5 a fatal error two levels down skips all three of the desk’s retries:

Failed: 'TechAgent' gave up after 1 attempt(s):
subgraph 'TechAgent' failed at 'Diagnose' after 1 attempt(s): subgraph 'Diagnose' failed at 'CheckFirmware' after 1 attempt(s): firmware 1.0 is too old to diagnose remotely
Persisted checkpoint: Failed at 'TechAgent', error_kind = fatal.
Its frozen state is the desk's Ticket from before TechAgent ran: log = ["triage: tech"]
It nests each child's own Failed checkpoint (schema_version 1.1.0):
thread 'TechAgent': Failed at 'Diagnose', state {"Attempts":0,"Fixed":false,"Reply":"","Steps":[]}
thread 'Diagnose': Failed at 'CheckFirmware', state {"Findings":["network ok"]}
Failed at: TechAgent/Diagnose/CheckFirmware

The desk’s checkpoint nests each child’s own Failed checkpoint, in its nested field. WaitingAt() / waiting_at() on the checkpoint gives the path. Once you fix the cause, a resume on the desk re-enters Diagnose at CheckFirmware. CheckNetwork doesn’t run again. Threads and checkpoints describes the nested field.

There is no separate error type for a subgraph failure. Match on NodeFailed for the subgraph node: GraphExecutionResult<TState>.NodeFailed in C#, GraphExecutionResult::Failed(StrataError::NodeFailed { .. }) in Rust. A Rust panic, or a C# exception that isn’t a NodeException, isn’t a managed failure: it propagates out of the run, as it does from any node.

A child can have interrupt boundaries of its own. When it reaches one, the whole thread pauses. Interrupts inside a subgraph explains the model. In short:

  • SuspendedNode is the subgraph node, and WaitingAt / waiting_at gives the full path.
  • A resume on the outer graph continues inside the child, after its boundary. input isn’t called again.
  • The signal’s overrides go to the innermost state. A LevelSignals also sends overrides to an outer level.
  • The child doesn’t inherit its parent’s decision. Pass what it needs through input.

In the desk, BillingAgent can pause at AwaitApproval before a refund. This resume approves the refund with a credit on the agent’s BillingCase, and changes the desk’s own Ticket at the same time:

var result = await desk.RunAsync("ticket-7", Ticket.New("I was charged twice for invoice 1042"), store);
if (result is GraphExecutionResult<Ticket>.Interrupted paused)
{
Console.WriteLine($"Paused at {string.Join("/", paused.WaitingAt)}");
}
// The first signal answers the innermost level: BillingAgent's boundary.
// .At([], ...) adds overrides for the desk's own state.
var signals = new LevelSignals(HumanSignal.Approve("dana@support.example").WithOverride("CreditCents", 500))
.At([], HumanSignal.Approve("dana@support.example").WithOverride("Category", "billing-priority"));
var resumed = await desk.ResumeAsync("ticket-7", signals, store);
if (resumed is GraphExecutionResult<Ticket>.Completed { State: var ticket })
{
Console.WriteLine($"Category: {ticket.Category}");
Console.WriteLine($"Reply: {ticket.Reply}");
}
Paused at BillingAgent/AwaitApproval
Category: billing-priority
Reply: We charged you twice and have refunded $49.99. We have also added a $5.00 credit.

A level is named by the subgraph nodes that lead to it: [] is the graph you resume, and ["BillingAgent"] is the child inside its BillingAgent node. An outer level’s overrides apply to the state its subgraph node started with, so they reach the output mapper and the nodes after it. Here the desk’s mapper keeps Category, so the override survives.

ToDefinition() (C#, in Datalium.Strata.Storage.Schema) or to_definition() (Rust) exports the parent. Each subgraph node has kind: "subgraph" and holds the child’s whole definition under graph, to any depth. The demo prints one line per level:

SupportDesk (schema_version 1.1.0): Triage, BillingAgent, TechAgent, GeneralReply, SendReply, Escalate
BillingAgent (schema_version 1.6.0): LookupInvoice, IssueRefund, DraftReply, AwaitApproval [boundary]
TechAgent (schema_version 1.1.0): Diagnose, ApplyFix, DraftReply
Diagnose (schema_version 1.0.0): CheckNetwork, CheckFirmware, Summarize

strata graph validate checks every level of such a file. See Graph definitions and CI and the CLI reference.

What the child takes from the parent, and what it doesn’t

Section titled “What the child takes from the parent, and what it doesn’t”
  • Hops. The subgraph node is one hop in the parent. The child counts its own nodes against the hop limit it was compiled with. See The hop limit.
  • Checkpoints. The outermost graph’s checkpoint policy applies at every depth. A child compiled with EveryNode inside a parent with the default policy writes nothing of its own. See When checkpoints are written.
  • Threads. A child is never a thread of its own. Its checkpoint exists only nested in the parent’s, and you load, resume and list only the outermost thread.
  • State. Everything crosses by value through the two mappers. The parent and child share no state.