Voluta.Generators
0.4.0
dotnet add package Voluta.Generators --version 0.4.0
NuGet\Install-Package Voluta.Generators -Version 0.4.0
<PackageReference Include="Voluta.Generators" Version="0.4.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="Voluta.Generators" Version="0.4.0" />
<PackageReference Include="Voluta.Generators"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add Voluta.Generators --version 0.4.0
#r "nuget: Voluta.Generators, 0.4.0"
#:package Voluta.Generators@0.4.0
#addin nuget:?package=Voluta.Generators&version=0.4.0
#tool nuget:?package=Voluta.Generators&version=0.4.0
Stateful agent graphs for .NET — cycles, typed channels, checkpoints, streaming, and human-in-the-loop. You describe nodes and edges; Voluta runs them in Pregel-style supersteps and can pause a thread for days, then resume in another process.
→ cycles not DAGs
→ typed channels not dictionary soup
→ checkpoints not in-process memory
→ .NET-native not a Python port
→ AOT-ready core not a kitchen sink
v0.4.0 is on NuGet. Shipped surface is frozen (
PublicAPI.Shipped.txt). Host time-travel (GetStateAsync/GetHistoryAsync/UpdateStateAsync/ForkAsync/Continue*) is available but still listed inPublicAPI.Unshipped.txt(can still move).dotnet add package Voluta --version 0.4.0— see Quick Start.
See it in action
A ReAct-style loop (agent ⇄ tools) with no LLM — pure simulation so you can run it offline.
Real output of dotnet run --project samples/HelloWorld:
◆ voluta · HelloWorld
simulated ReAct · agent ⇄ tools · no LLM
thread react-sample-1
stream Updates
────────────────────────────────────────
[agent] round 0 · requesting tools
· step 1 Updates [agent]
status ← tools
messages ← agent: call get_weather (round 1)
[tools] executing simulated tool · round 1
· step 2 Updates [tools]
tool_rounds ← 1
messages ← tools: observation — temp=12C (round 1)
status ← agent
…
[agent] enough tool data · finishing
· step 5 Updates [agent]
status ← done
messages ← agent: final answer — cloudy, 12°C in Oslo
· step 5 End [—]
▸ result
status Done
✓ done
The loop exists because one edge is conditional. messages accumulates (Append);
status replaces (LastValue):
var graph = new StateGraph()
.AddChannel("messages", ChannelKind.Append)
.AddChannel("status", ChannelKind.LastValue)
.AddChannel("tool_rounds", ChannelKind.LastValue)
.AddNode("agent", AgentNodeAsync)
.AddNode("tools", ToolsNodeAsync)
.AddEdge(GraphConstants.Start, "agent")
.AddConditionalEdges(
"agent",
static context => context.Read<string>("status") == "tools" ? "tools" : GraphConstants.End)
.AddEdge("tools", "agent")
.Compile(checkpointer, new CompileOptions { RecursionLimit = 32 });
await foreach (var item in graph.StreamAsync(
input,
new RunOptions { ThreadId = "react-1", StreamMode = StreamMode.Updates }))
{
// item.Kind, item.NodeNames, item.Writes
}
<details> <summary><strong>Human-in-the-loop — interrupt, then resume (even later, another process)</strong></summary>
A node returns NodeResult.Interrupt instead of writes. The run stops, the checkpoint holds the
payload, and ResumeInvokeAsync continues with a Command. Real output of
samples/InterruptResume:
▸ invoke · expect interrupt
· step 0 Start [—]
[gate] interrupting for human approval
· step 1 Interrupt [gate]
payload ← { action = transfer, amount = 50, currency = USD }
checkpoint Interrupted
▸ resume · Command.Kind = approve
[gate] resumed · payload=ok · approving
· step 2 End [—]
status Done
✓ done
static Task<NodeResult> GateNodeAsync(GraphContext context, CancellationToken cancellationToken)
{
if (!context.IsResume)
{
return Task.FromResult<NodeResult>(
NodeResult.Interrupt(new { action = "transfer", amount = 50, currency = "USD" }));
}
return Task.FromResult<NodeResult>(
NodeResult.Continue(new ChannelWrite("messages", "gate: transfer approved")));
}
// first process
var terminal = await graph.InvokeAsync(input, new RunOptions { ThreadId = "hitl-1" });
// terminal.Kind == StreamEventKind.Interrupt
// later — same or new process, same checkpointer root / store
// closed kinds: Command.Approve / Reject / Update (Command.Kinds.*)
var done = await graph.ResumeInvokeAsync(
"hitl-1",
Command.Approve(
"ok",
// optional: patch channels before the node re-runs
new Dictionary<string, object?> { ["decision"] = "go" }));
</details>
<details> <summary><strong>Time-travel read (GetState / GetHistory)</strong></summary>
ICheckpointer is the storage seam (shipped). There is no putWrites — a put is always a full C-shape snapshot:
| Method | Contract |
|---|---|
PutAsync(CheckpointSnapshot) |
Persist this snapshot (and its step in history when the provider supports it) |
GetAsync(threadId) |
Latest snapshot only. Miss returns null (does not throw) |
ListAsync(threadId) |
Full step history, oldest first. Providers that cannot list must throw NotSupportedException rather than return a silent partial |
InMemory, File, EF, S3, and Redis all implement history. IVolutaStore is a separate cross-thread KV (Put/Get/List/Delete by namespace) — ListAsync(namespace) lists all keys in that namespace, not checkpoints.
Host-facing projection — no need to spelunk C-shape fields. These methods are available (PublicAPI.Unshipped.txt, not frozen):
// latest snapshot for ops / HTTP / UI (wraps ICheckpointer.GetAsync)
var state = await graph.GetStateAsync("order-9");
// state?.Status, state?.Step, state?.Values, state?.InterruptPayload
// full step history, oldest first (wraps ICheckpointer.ListAsync)
var history = await graph.GetHistoryAsync("order-9");
// ordered by Step ascending; history[^1] matches GetState when present
Ops UI: GET /voluta/api/threads/{id}/history lists steps; inspector shows them after Load.
</details>
<details> <summary><strong>Update state / fork / continue</strong></summary>
Ops and support workflows can edit channel values and branch threads without re-invoking from scratch.
There is no putWrites. Channel edits go through UpdateStateAsync(threadId, IEnumerable<ChannelWrite>)
(Unshipped): it loads the latest checkpoint, applies the same LastValue / Append
reducers as runtime input seeding, and Puts a new history step.
// patch latest checkpoint (Append / LastValue reducers apply)
var patched = await graph.UpdateStateAsync(
"order-9",
[new ChannelWrite("messages", "ops-note")]);
// branch a new thread from history step N (same step index on the new thread)
var fork = await graph.ForkAsync(sourceThreadId: "order-9", step: 2, newThreadId: "order-9-retry");
// re-drive a Running thread after update/fork (not for Interrupted — use Resume*)
// WARNING: nodes in NextNodes re-execute; make side effects idempotent
var terminal = await graph.ContinueInvokeAsync("order-9-retry");
- Interrupted → still use
ResumeAsync/ResumeInvokeAsync(optionally afterUpdateStateAsync). - Done →
UpdateStateAsyncmay still patch values;Continue*throwsgraph.invalid_continue. - Failed/Cancelled → update promotes status to Running so continue can re-drive last NextNodes.
</details>
<details> <summary><strong>Typed state with <code>[GraphState]</code> (source generator)</strong></summary>
String channel names work. When you want compile-time names and updates that only write what you set:
[GraphState]
public partial class ReviewState
{
[Channel(ChannelKind.Append)]
public IList<object?> Notes { get; set; } = new List<object?>();
[Channel(ChannelKind.LastValue)]
public string? Verdict { get; set; }
}
var graph = new StateGraph()
.AddChannels(ReviewState.CreateSchema())
.AddNode(
"review",
static (context, cancellationToken) => Task.FromResult<NodeResult>(
NodeResult.Continue(
new ReviewState.ReviewStateUpdate { Verdict = "approved" }.ToWrites())))
.AddEdge(GraphConstants.Start, "review")
.AddEdge("review", GraphConstants.End)
.Compile(new InMemoryCheckpointer());
Unset properties emit no write. An explicit null is a clear. Interface-typed properties use
OptionalValue<T>.Of(value).
</details>
<details> <summary><strong>Typed channels and custom reducers</strong></summary>
Checkpoints stay dumb JSON (allow-listed primitives / lists / string-key dicts). Types live on the
compiled graph. AddChannel<T> records T so restore coerces wire values (long → int,
JSON object text → Dictionary<string, string>, append list elements to string). Untyped
AddChannel(name, kind) still works and coexists on the same graph.
var graph = new StateGraph()
.AddChannel<int>("score", ChannelKind.LastValue)
.AddChannel<string>("messages", ChannelKind.Append)
.AddChannel("notes", ChannelKind.Append) // untyped
.AddChannel<Dictionary<string, object?>>("artifacts", new DictMergeReducer())
.AddNode("left", LeftAsync)
.AddNode("right", RightAsync)
.AddEdge(GraphConstants.Start, "left")
.AddEdge(GraphConstants.Start, "right")
.AddEdge("left", GraphConstants.End)
.AddEdge("right", GraphConstants.End)
.Compile(checkpointer);
IChannelReducer is public; IChannel stays internal. Built-ins: LastValueReducer,
AppendReducer, DictMergeReducer. Custom reducers use a separate AddChannel(name, reducer)
overload — ChannelKind stays LastValue | Append. Durable restore contract:
FileCheckpointerRuntimeShould.RehydrateTypedChannelsViaDeclaredValueType.
</details>
<details> <summary><strong>Durable file checkpoint (survive process restart)</strong></summary>
// Host DI (recommended) — same root on every process
services.AddVoluta(v =>
{
v.Checkpoints.UseFile("/var/lib/voluta/threads");
v.Graph((sp, checkpointer) => new StateGraph()
// … nodes …
.Compile(checkpointer, new CompileOptions { Services = sp }));
});
// process A
var graph = sp.GetRequiredService<CompiledGraph>();
await graph.InvokeAsync(input, new RunOptions { ThreadId = "order-9" });
// → Interrupted
// process B (new host, same UseFile root) — resume
await graph.ResumeInvokeAsync("order-9", Command.Approve("ok"));
Values serialize with System.Text.Json — prefer JSON-friendly types (strings, numbers, lists of
primitives). Durable rehydrate is lossy: File / EF / S3 / Redis FromElement restores
string / bool / long / double and arrays as List<object?>. JSON objects ({}) become
GetRawText() — a JSON string, not the original CLR type and usually not JsonElement.
InMemoryCheckpointer keeps CLR references (no JSON). Declare AddChannel<T> so the compiled
graph coerces those wire values on restore. Custom POCOs still fail Put unless they are
allow-listed shapes.
For tests, InMemoryCheckpointer is enough; every storage implements ICheckpointer and can
run CheckpointerConformance.RunAllAsync. Provider types (FileCheckpointer, EF, S3, Redis)
are constructed via Use* only — not new.
</details>
<details> <summary><strong>Fan-out with <code>Send</code> / subgraphs</strong></summary>
// Map one item to many dynamic tasks (runtime-scheduled nodes)
return NodeResult.ContinueWithSends(
new Send("worker", payload: orderLine1),
new Send("worker", payload: orderLine2));
// Nest a compiled graph as a single node (stable child thread: parentId/child)
.AddNode("child", Subgraph.AsNode(
childCompiledGraph,
inputChannels: ["messages"],
outputChannels: ["result"]));
// Custom multi-agent nest namespace:
// threadIdFactory: ctx => $"{ctx.ThreadId}/agent/{ctx.NodeName}"
Child interrupt → parent interrupt (same HITL Command resume on the parent
thread resumes the nested child). Default nested checkpoint key:
{parentThreadId}/{nodeName}.
CompiledGraph.Describe() returns topology (nodes, edges, channels) for the ops UI or docs.
</details>
<details> <summary><strong>Host with DI + ops console</strong></summary>
// Program.cs
builder.Services.AddVoluta(sp =>
{
var checkpointer = sp.GetRequiredService<ICheckpointer>();
return new StateGraph()
// …
.Compile(checkpointer);
});
// Optional ops UI (ASP.NET)
var session = new VolutaUiSession(graph, checkpointer);
builder.Services.AddVolutaUI(session);
app.MapVolutaUI(options => options.PathPrefix = "/voluta");
app.MapStudioApi(); // versioned /api/v1 for the SPA and any FE client
// → Studio SPA: http://localhost:5188/voluta/studio
// → Legacy shell: http://localhost:5188/voluta (see samples/UiHost)
</details>
<details> <summary><strong>Testing without fighting the runtime</strong></summary>
Voluta.Testing ships doubles you’d otherwise write by hand:
| Helper | Role |
|---|---|
RecordingCheckpointer |
Records every Put/Get/List |
FaultInjectingCheckpointer |
Fails the n-th write |
CheckpointerConformance.RunAllAsync |
Suite every ICheckpointer must pass |
GraphFixtures.Linear() / .Cycle() / .Interrupt() |
Ready graphs |
StreamCapture |
Drain a stream in tests |
Unit tests cover linear/conditional/cycle, fan-out, Send, HITL (Command.Values, resume payload),
stream modes (Updates / Events), Append flatten vs string, LastValue sequential overwrite, and
File rehydrate + resume.
dotnet test voluta.slnx
</details>
<details> <summary><strong>Sample: Hybrid-style marketing desk (mock tools)</strong></summary>
Not a product agent — a demo that exercises the graph against Hybrid console.platform nouns (Campaign, SSP, DirectDeal, AdLibrary):
# terminal 1
dotnet run --project samples/MockAdMcp # http://localhost:5190
# terminal 2
dotnet run --project samples/MarketingAgent -- --offline
Flow: brief → creative → setup (create RK → SSP → banner → Active) → review. Tools are a
simplified HTTP tools surface for demos — not the official MCP SDK and not live Hybrid API.
</details>
Why Voluta
- Cycles are the point — think → act → observe is a loop. Conditional edges +
RecursionLimitmake loops terminate on purpose. - The run outlives the process — every superstep can checkpoint. Interrupt, inspect, resume, or
replay from storage;
ICheckpointeris the only seam. - Multi-writer state is defined — same-superstep writes go through reducers (
Append/LastValue). ConcurrentLastValuewriters fail fast instead of last-write-wins races. - Core stays small —
Voluta+ Abstractions + DI areIsAotCompatible, zero third-party deps on the hot path. No LLM SDK, no logging framework required to run a graph.
Voluta is orchestration, not a Claude Code / OpenCode clone. You bring tools, MCP, prompts, and policy; Voluta runs the stateful graph.
Quick Start
Requires .NET 10 SDK (dotnet --version ≥ 10.0.100).
Scaffold a HITL agent (no clone required once the template pack is installed):
dotnet new install Voluta.Templates
dotnet new voluta-agent -n MyAgent
cd MyAgent && dotnet run
Or clone and run in-repo samples:
git clone https://github.com/dot-stbl/voluta.git
cd voluta
dotnet build voluta.slnx
dotnet run --project samples/HelloWorld
dotnet run --project samples/InterruptResume
Reference from your app:
dotnet add package Voluta --version 0.4.0
# or, from a clone:
dotnet add reference path/to/voluta/src/Voluta/Voluta.csproj
Minimal complete program
Writer + critic loop until score ≥ 8 — prints End after N supersteps:
using Voluta;
using Voluta.Abstractions.Channels;
using Voluta.Abstractions.Results;
using Voluta.Abstractions.Runtime;
using Voluta.Checkpoint;
using Voluta.Graph;
using Voluta.Graph.Builder;
using Voluta.Graph.Options;
var graph = new StateGraph()
.AddChannel("draft", ChannelKind.LastValue)
.AddChannel("notes", ChannelKind.Append)
.AddChannel("score", ChannelKind.LastValue)
.AddNode("write", WriteAsync)
.AddNode("critique", CritiqueAsync)
.AddEdge(GraphConstants.Start, "write")
.AddEdge("write", "critique")
.AddConditionalEdges(
"critique",
static context => (context.Read<int?>("score") ?? 0) < 8 ? "write" : GraphConstants.End)
.Compile(new InMemoryCheckpointer(), new CompileOptions { RecursionLimit = 16 });
var final = await graph.InvokeAsync(
[new ChannelWrite("draft", "checkpoints are nice")],
new RunOptions { ThreadId = "post-42" });
Console.WriteLine($"{final.Kind} after {final.Step} supersteps");
static Task<NodeResult> WriteAsync(GraphContext context, CancellationToken cancellationToken)
{
var round = (context.Read<int?>("score") ?? 0) / 4;
return Task.FromResult<NodeResult>(
NodeResult.Continue(
new ChannelWrite("draft", $"revision {round + 1}"),
new ChannelWrite("notes", $"writer: produced revision {round + 1}")));
}
static Task<NodeResult> CritiqueAsync(GraphContext context, CancellationToken cancellationToken)
{
var score = (context.Read<int?>("score") ?? 0) + 4;
return Task.FromResult<NodeResult>(
NodeResult.Continue(
new ChannelWrite("score", score),
new ChannelWrite("notes", $"critic: scored {score}/10")));
}
Swap InvokeAsync for StreamAsync (StreamMode.Values / Updates / Events). Under a host:
services.AddVoluta(provider => …).
DI nodes + Microsoft AI (optional)
Native path — no extension-method glue:
// 1) IGraphNode + DI (core)
services.AddSingleton<PlanNode>();
services.AddVoluta(v =>
{
v.Checkpoints.UseInMemory();
v.Graph((sp, checkpointer) => new StateGraph()
.AddNode<PlanNode>("plan")
.AddEdge(GraphConstants.Start, "plan")
.AddEdge("plan", GraphConstants.End)
.Compile(checkpointer, new CompileOptions { Services = sp }));
});
// 2) MEAI IChatClient as a node (Voluta.Agents.AI)
ChatClientNodes.Add(
graph, "answer", "answer",
context => [new ChatMessage(ChatRole.User, context.Read<string>("question") ?? "")],
stream: true,
usageChannel: "usage",
toolCallsChannel: "tool_calls");
// 3) Microsoft Agent Framework AIAgent as a node
AgentNodes.Add(graph, "research", agent, outputChannel: "draft", inputChannel: "question");
ChatClientNodes covers: assistant text → OutputChannel; optional Stream token
deltas via GraphContext.Stream (StreamEventKind.Messages); optional UsageChannel
(ChatClientUsage from ChatResponse.Usage, best-effort on stream from UsageContent);
optional ToolCallsChannel (Append-friendly List<object?> of ChatClientToolCall,
empty when the model made no calls). Windowing and ReAct routing stay in the host graph.
Long-running / workers
HITL and multi-minute agent turns must not pin to an HTTP request. Pattern:
- Wake a
threadId(queue, bus, or in-process channel). - Invoke or ResumeInvoke until the stream hits interrupt / end / fail.
- Park on interrupt (checkpoint is source of truth — process may exit).
- Complete on done; dead-letter / alert on fail (last-good checkpoint remains;
do not
ResumeInvokea Failed thread).
Package: Voluta.Hosting — IThreadWakeBus, InMemoryThreadWakeBus,
GraphThreadRunner, GraphWorkerService, AddVolutaWorkerHosting().
Sample: samples/WorkerHost. No Hangfire/Quartz; NATS/SQS
are host-owned implementations of the interface.
services.AddVoluta(v => { /* checkpoints + graph */ });
services.AddVolutaWorkerHosting(); // IThreadWakeBus + BackgroundService worker
// producer (HTTP approve, bus consumer, …)
await wakes.EnqueueAsync(ThreadWake.Start(threadId, inputWrites));
// later, after human approval:
await wakes.EnqueueAsync(ThreadWake.Resume(threadId, Command.Approve("ok")));
Multi-instance (k8s scale-out)
- Use a shared durable checkpointer (File / EF / S3 / Redis), not in-memory, across replicas.
- Wakes are hints; the checkpoint decides invoke vs resume vs already-terminal.
- Partition or lease by
threadIdso two pods do not run the same thread at once. - Use a shared durable checkpointer (File / EF / S3 / Redis), not in-memory, across replicas. Checkpointer is the source of truth; wakes are only hints.
- Replace
InMemoryThreadWakeBuswith a durable queue implementingIThreadWakeBus. - Partition or lease by
threadIdso two pods do not run the same thread at once (GraphWorkerServiceonly dedupes in-flight wakes within one process). - Interrupt park is multi-process safe: pod A parks, pod B resumes hours later against the same store.
Host DI — AddVoluta
One composition root: checkpoints + graph (and later UI hooks). Prefer this over raw
AddSingleton<ICheckpointer>(…) / ad-hoc factories.
// Recommended: checkpoints + graph in one call
services.AddVoluta(v =>
{
v.Checkpoints.UseInMemory(); // or UseFile / UseEntityFrameworkCore / UseS3 / UseRedis
v.Graph((sp, checkpointer) => new StateGraph()
// …nodes, edges, channels…
.Compile(checkpointer, new CompileOptions { Services = sp }));
});
// Graph only (already compiled, or factory that owns its own checkpointer)
services.AddVoluta(prebuiltGraph);
services.AddVoluta(sp => BuildGraph(sp));
// Checkpoints only (resolve ICheckpointer yourself)
services.AddVolutaCheckpoints(c => c.UseFile("./.voluta/checkpoints"));
Checkpoint providers (v.Checkpoints.Use*)
Exactly one Use* per host:
v.Checkpoints.UseInMemory();
v.Checkpoints.UseFile("./.voluta/checkpoints");
// EF — register IDbContextFactory first (any provider: Npgsql, SqlServer, SQLite, …)
services.AddDbContextFactory<AppDbContext>(o => o.UseNpgsql(connectionString));
v.Checkpoints.UseEntityFrameworkCore<AppDbContext>();
// S3 — register IAmazonS3 first
services.AddSingleton<IAmazonS3>(_ => new AmazonS3Client(RegionEndpoint.EUCentral1));
v.Checkpoints.UseS3(o =>
{
o.BucketName = "voluta";
o.KeyPrefix = "runs";
});
// Redis — register IConnectionMultiplexer first
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect("localhost:6379"));
v.Checkpoints.UseRedis(o =>
{
o.KeyPrefix = "voluta:";
// o.Database = 0;
});
<details> <summary><strong>Host DbContext shape (EF)</strong></summary>
public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options), IVolutaCheckpointDbContext
{
public DbSet<CheckpointRecord> Checkpoints => Set<CheckpointRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyVolutaCheckpointModel(); // table voluta_checkpoints
// …your entities…
}
}
Schema ownership is yours: migrations or EnsureCreated() in tests. Column names follow
your EF naming convention (no forced snake_case). Snapshot payload is a JSON column via
EF value conversion — the checkpointer itself does not call JsonSerializer.
</details>
<details> <summary><strong>Errors and limits</strong></summary>
- Miss on Get →
null(not an exception). - Storage failures →
CheckpointStoreExceptionwith stableCode(checkpoint.put_failed/get_failed/list_failed). Graph logic still usesGraphExceptionand friends. - Channel values (wire format v1) on File / EF / S3 / Redis: allow-list only —
null, primitives, string,Guid, date/time,JsonElement,byte[], lists and string-key dictionaries of those (depth ≤ 8). Unsupported types fail Put withcheckpoint.unsupported_value_type(no silent loss). InMemory is process-local and does not enforce the allow-list. - S3 keys:
{prefix}/{safeThreadId}/{step:D12}.json.
Every provider is exercised by CheckpointerConformance.RunAllAsync in Voluta.Testing
(InMemory, File, EF SQLite + EF InMemory, S3 with a fake client).
Full code + EventId catalog and host HTTP mapping sample: Error codes & EventIds.
</details>
Error codes & EventIds
Voluta does not ship an ASP.NET package. Exceptions carry a stable Code (dot.case);
hosts map codes to HTTP/gRPC/worker outcomes themselves. Catalog types:
| Type | Package | Role |
|---|---|---|
VolutaErrorCodes |
Voluta.Abstractions |
Stable code strings |
VolutaEventIds |
Voluta |
MEL EventId (id + name = code) |
VolutaExceptionLogging.GetEventId(...) |
Voluta |
Code / exception → EventId |
Log full exception + Code + EventId; never put secrets or PII in Message.
catch (GraphException exception)
{
logger.LogError(
VolutaExceptionLogging.GetEventId(exception),
exception,
"Graph failed with {ErrorCode}",
exception.Code);
// host maps exception.Code → ProblemDetails / status
}
Code → suggested HTTP status (host sample)
| Code | Typical status | Notes |
|---|---|---|
graph.invalid_* / graph.duplicate_* / graph.no_nodes / graph.missing_start / graph.unknown_endpoint |
400 | Compile-time topology errors |
graph.invalid_resume |
409 | Resume when thread is not interrupted |
graph.out_of_steps |
422 or 400 | Recursion limit; product choice |
graph.run_failed |
500 | Uncaught node/superstep fault |
channel.concurrent_update |
409 | LastValue multi-writer in one superstep |
checkpoint.put_failed / get_failed / list_failed / corrupt_payload |
502 / 500 | Store / IO / corrupt payload |
checkpoint.unsupported_format_version |
409 or 500 | Wire newer than this package |
checkpoint.unsupported_value_type |
400 | Reserved (serde #31) |
command.invalid_kind / command.invalid_payload |
400 | Reserved (command taxonomy #32) |
Get miss stays null — not an exception. Interrupt control flow stays
NodeResult.Interrupt (not exceptions).
<details> <summary><strong>Failure & recovery (checkpoint policy)</strong></summary>
When a node throws, a superstep merge fails, or the recursion limit is hit:
- Stream surfaces a
Failedevent (and the invoke/stream API rethrows the graph fault). - Checkpoint Put writes a terminal snapshot at the failing superstep with
GraphRunStatus.Failed(orCancelledon cooperative cancel). Incomplete writes from the failed superstep are not applied. - Channel values on that terminal snapshot are the last successful apply — last-good payload, not a wipe and not a half-merge.
- Get returns that latest terminal document (
Status = Failed, last-good channels). - List (when supported) still enumerates earlier
Running/Interrupted/Donesteps for tooling; prior step keys are never overwritten by the failure put. - Resume (
ResumeInvokeAsync) still requiresInterrupted— Failed/Cancelled are terminal for HITL resume. Hosts re-invoke with a new thread, or rebuild input from last-good channels / list history.
Contracts: openspec/specs/error-cancellation/.
</details>
How a superstep works
One tick, in order: collect ready nodes → run them concurrently → barrier → merge writes through channel reducers → checkpoint → evaluate edges.
- Nodes in the same superstep never see each other’s writes (barrier isolation).
- Conditional edges run after the merge, on committed state.
Contracts: openspec/specs/ (12 capabilities).
How it compares
vs. LangGraph (Python) — Origin of the execution
model (supersteps, channels, checkpoint-first). Voluta rebuilds the surface on .NET generics, typed
reducers, and IAsyncEnumerable. Not a port; a peer.
vs. Microsoft Agent Framework — Better for multi-agent chat and function calling. It does not give cyclic graphs with durable per-thread state. Compose: run MAF agents inside Voluta nodes.
vs. Durable Functions / Durable Task — Stronger storage ecosystem for business workflows. Orchestrator + replay vs graph + explicit channels — Voluta fits think-act-observe loops more directly.
vs. a hand-rolled while — Works until “state at step 7?”, “resume after restart?”, or “two
nodes wrote the same field”. Those three questions are the library.
Packages
How-to for checkpoints (DI Use* + host DbContext) is under
Durable checkpoints. Behavior contracts:
openspec/specs/checkpoint/. Until NuGet publishes,
browse source under src/ (each package is one folder; no per-package README yet).
| Package | Role | Source |
|---|---|---|
Voluta.Abstractions |
Contracts: channels, checkpoints, NodeResult, Send, streaming |
src/core/Voluta.Abstractions |
Voluta |
Pregel runtime + InMemory + Subgraph.AsNode + Describe() |
src/core/Voluta |
Voluta.DependencyInjection |
AddVoluta(v => { v.Checkpoints…; v.Graph… }) |
src/core/Voluta.DependencyInjection |
Voluta.OpenTelemetry |
AddVolutaInstrumentation() for OTel Tracer/Meter providers |
src/hosting/Voluta.OpenTelemetry |
Voluta.Generators |
[GraphState] source generator |
src/devtools/Voluta.Generators |
Voluta.Testing |
Test doubles + checkpointer conformance suite | src/devtools/Voluta.Testing |
Voluta.Checkpoints.File |
JSON file-system checkpointer (UseFile) |
src/checkpoints/Voluta.Checkpoints.File |
Voluta.Checkpoints.EntityFrameworkCore |
Provider-agnostic EF Core (UseEntityFrameworkCore<T>) |
src/checkpoints/Voluta.Checkpoints.EntityFrameworkCore |
Voluta.Checkpoints.S3 |
AWS S3 / S3-compatible (UseS3) |
src/checkpoints/Voluta.Checkpoints.S3 |
Voluta.Checkpoints.Redis |
Redis sorted-set checkpointer (UseRedis) |
src/checkpoints/Voluta.Checkpoints.Redis |
Voluta.Agents.AI |
MAF AIAgent + MEAI as IGraphNode |
src/agents/Voluta.Agents.AI |
Voluta.Hosting |
Wake bus + GraphWorkerService presets |
src/hosting/Voluta.Hosting |
Voluta.UI |
Studio SPA (React + Fluent) + legacy shell + /api/v1 via MapStudioApi |
src/ops/Voluta.UI |
Native AOT applies to the core tier only — Voluta, Abstractions, and
DependencyInjection are IsAotCompatible, with a publish smoke test in samples/AotSmoke.
Checkpoint providers (File / EF / S3 / Redis), UI, Agents.AI, Hosting, and OpenTelemetry
are regular-CLR packages and do not claim AOT.
OpenTelemetry
Core Voluta always emits BCL ActivitySource / Meter named "Voluta" (no OTel SDK dependency).
Wire the SDK with Voluta.OpenTelemetry:
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Voluta.OpenTelemetry;
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddVolutaInstrumentation()
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddVolutaInstrumentation()
.AddOtlpExporter());
Spans: voluta.superstep, voluta.node.execute, voluta.checkpoint.put|get|list.
Metrics: voluta.superstep.duration, voluta.node.duration (ms), voluta.interrupt.count,
voluta.checkpoint.*.count, voluta.stream.dropped. Tags: node.name, run.status,
error.type, provider.name, stream.kind (no raw thread ids).
No library ILogger output — hosts log exceptions via VolutaExceptionLogging.GetEventId
VolutaErrorCodes. Full catalog:docs/0.x/concepts/observability.mdx.
<details> <summary><strong>Production hardening (Continue / multi-HITL / stream)</strong></summary>
- Continue with
PendingSends: schedules incomplete push tasks only — does not re-pullNextNodes(avoids double side effects after a completed map+Send). - Multi-interrupt:
Command.Resumesmay be a partial map; remainingPendingInterruptsstay Interrupted until covered. Unknown task ids failcommand.invalid_payload. - Live Custom/Messages: bounded buffer (capacity 256); overflow drops and increments
voluta.stream.dropped(stream.kindtag). Order is best-effort per node.
See OpenSpec checkpoint / hitl-interrupt / streaming and issue #63.
</details>
Samples
| Sample | What it shows |
|---|---|
HelloWorld |
Cyclic agent ⇄ tools, StreamMode.Updates |
InterruptResume |
HITL interrupt + Command resume |
AotSmoke |
Native AOT publish smoke |
ReviewBot |
CLI: plan → sandbox tools → review (+ optional HITL) |
DocQ |
Docs Q&A over a sandboxed folder |
MarketingAgent |
Hybrid desk setup over mock tools |
MockAdMcp |
Hybrid-shaped tool server (Campaign / SSP / deal / AdLibrary) |
UiHost |
ASP.NET host: MapVolutaUI + MapStudioApi + Studio SPA |
StudioHost |
Studio /api/v1 contract host with seeded demo threads |
WorkerHost |
Durable BackgroundService runner (wake / park / resume) |
Index: samples/README.md.
dotnet run --project samples/HelloWorld
dotnet run --project samples/InterruptResume
dotnet run --project samples/WorkerHost
dotnet run --project samples/ReviewBot -- --offline --root .
dotnet run --project samples/DocQ -- --offline --root . --question "What is Voluta?"
dotnet run --project samples/MockAdMcp # :5190
dotnet run --project samples/MarketingAgent -- --offline
dotnet run --project samples/UiHost # Studio: http://localhost:5188/voluta/studio
What isn't here yet
Stated plainly so you can judge the fit:
- PublicAPI surface can still move before a major bump (tracked with PublicApiAnalyzers).
Shipped surface up to
v0.4.0is frozen inPublicAPI.Shipped.txt. Host time-travel (GetStateAsync/GetHistoryAsync/UpdateStateAsync/ForkAsync/Continue*) is available but still Unshipped. - Studio SPA auth is not built in;
/api/v1supports an optional single API key (StudioApiOptions.ApiKey). Multi-tenant auth is out of scope for now. - Checkpoint serde is JSON at the store;
AddChannel<T>coerces declared types on restore (see Typed channels and Durable file checkpoint). Versioning/evolution is still open. - MCP in samples is a light HTTP bridge (inlined in MarketingAgent), not a product package;
real MCP is
ModelContextProtocol(+ AspNetCore) on top of Voluta. - No built-in coding agent (bash/edit/permissions) — Voluta is the graph runtime, not Claude Code.
Documentation
Product docs live in docs/ (product.md + version trees such as 0.x/**/*.mdx).
On every v*.*.* tag the publish workflow packs them into docs.tgz (tarball root =
product.md + 0.x/…, not nested under an extra docs/ folder) and attaches the asset to
the GitHub Release for that tag. Public site: docs.stbl.space/voluta.
# local dry-run of the release asset layout
tar -czf docs.tgz -C docs .
tar -tzf docs.tgz | head
Development
dotnet build voluta.slnx # 0 warnings, 0 errors — the gate
dotnet test voluta.slnx # xUnit + Shouldly + NSubstitute
dotnet format voluta.slnx --severity hidden # style drift
dotnet run -c Release --project benchmarks/Voluta.Benchmarks
TreatWarningsAsErrors + EnforceCodeStyleInBuild are on. Specs:
openspec/specs/. Agent handbook: CLAUDE.md.
Contributing
Small fixes → PR. Non-trivial changes → issue first (contracts still move).
git config core.hooksPath .githooks # once per clone
Commit shape: [voluta](feat/scope): subject. Details:
CONTRIBUTING.md.
Inspiration
Execution model from LangGraph (MIT) — supersteps, channel/reducer state, checkpoint-first persistence. The API diverges substantially; design mistakes are ours, not theirs.
License
MIT — see LICENSE.
Learn more about Target Frameworks and .NET Standard.
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
