Table of Contents

Graph-Based Orchestration for C# .NET Applications


🎯 What You Will Build

A composable agent graph that combines sequential, parallel, and conditional execution in one workflow. The prebuilt orchestrators (PipelineOrchestrator, ParallelOrchestrator, RouterOrchestrator, SupervisorOrchestrator) each express one fixed pattern. The graph layer added in LM-Kit.NET 2026.5.2 lets you express any combination of these patterns by composing nodes.

This guide ships a single self-contained Program.cs you can paste into a fresh console project and run. It demonstrates every node type:

  • IOrchestrationNode: the unit of composition.
  • AgentNode: leaf node wrapping a single Agent.
  • SequentialNode, ParallelNode, ConditionalNode: composite nodes.
  • A custom IOrchestrationNode (UpperCaseNode) for non-agent work.
  • GraphOrchestrator: the host that runs an arbitrary node graph.

The prebuilt orchestrators remain available. Pick the graph layer when you need a shape they do not provide.


✅ Prerequisites

  • .NET 8.0 or later.
  • LM-Kit.NET 2026.5.2 or later.
  • Roughly 6 GB of free VRAM (CPU inference works too, just slower).
  • The qwen3.5:9b model. It is downloaded on first run from the HuggingFace mirror.

Add the LM-Kit.NET package to a new console project:

dotnet new console -n GraphOrchestrationDemo
cd GraphOrchestrationDemo
dotnet add package LM-Kit.NET --prerelease

📦 Complete Copy-Paste Program

Replace the contents of Program.cs with the snippet below. It compiles as-is and runs end to end.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

using LMKit.Agents;
using LMKit.Agents.Orchestration;
using LMKit.Agents.Orchestration.Nodes;
using LMKit.Model;
using LMKit.TextGeneration.Chat;
using LMKit.TextGeneration.Sampling;

internal static class Program
{
    private static async Task Main()
    {
        Console.OutputEncoding = Encoding.UTF8;

        // Optional: license key. Trial mode runs without one.
        // LMKit.Licensing.LicenseManager.SetLicenseKey("YOUR-KEY-HERE");

        Console.WriteLine("Loading qwen3.5:9b...");
        using var model = LM.LoadFromModelID("qwen3.5:9b");
        Console.WriteLine("Loaded.\n");

        await DemoSingleAgentNodeAsync(model);
        await DemoSequentialNodeAsync(model);
        await DemoParallelNodeAsync(model);
        await DemoConditionalNodeAsync(model);
        await DemoCustomNodeAsync(model);
        await DemoNestedGraphAsync(model);

        Console.WriteLine("\nAll graphs ran. Exiting 0.");
    }

    // 1. AgentNode: the smallest possible graph (one agent).
    private static async Task DemoSingleAgentNodeAsync(LM model)
    {
        Console.WriteLine("[1] AgentNode (single agent wrapped in graph):");

        var writer = Agent.CreateBuilder(model)
            .WithPersona("Writer")
            .WithInstruction("Reply with one short tagline (under 8 words).")
            .Build();

        IOrchestrationNode graph = new AgentNode("write", writer);
        var orchestrator = new GraphOrchestrator(graph);

        var result = await orchestrator.ExecuteAsync(
            "Tagline for a coffee brand",
            BuildOptions(maxTokens: 32));

        Console.WriteLine($"  -> {result.Content?.Trim()}\n");
    }

    // 2. SequentialNode: pipe outputs from one agent to the next.
    private static async Task DemoSequentialNodeAsync(LM model)
    {
        Console.WriteLine("[2] SequentialNode (research then write):");

        var researcher = Agent.CreateBuilder(model)
            .WithPersona("Researcher")
            .WithInstruction("List 2 short bullet facts about the topic.")
            .Build();

        var writer = Agent.CreateBuilder(model)
            .WithPersona("Writer")
            .WithInstruction("Turn the bullets into one concise sentence.")
            .Build();

        var graph = new SequentialNode("research-then-write",
            new AgentNode("research", researcher),
            new AgentNode("write", writer));

        var orchestrator = new GraphOrchestrator(graph);
        var result = await orchestrator.ExecuteAsync(
            "Topic: edge AI",
            BuildOptions(maxTokens: 96));

        Console.WriteLine($"  -> {result.Content?.Trim()}\n");
    }

    // 3. ParallelNode: same input, two agents, aggregated output.
    private static async Task DemoParallelNodeAsync(LM model)
    {
        Console.WriteLine("[3] ParallelNode (two perspectives in parallel):");

        var optimist = Agent.CreateBuilder(model)
            .WithPersona("Optimist")
            .WithInstruction("Reply with one upbeat sentence.")
            .Build();

        var pessimist = Agent.CreateBuilder(model)
            .WithPersona("Pessimist")
            .WithInstruction("Reply with one cautious sentence.")
            .Build();

        var graph = new ParallelNode("perspectives", new IOrchestrationNode[]
        {
            new AgentNode("optimist", optimist),
            new AgentNode("pessimist", pessimist)
        });

        var orchestrator = new GraphOrchestrator(graph);
        var result = await orchestrator.ExecuteAsync(
            "Cloud computing trade-offs",
            BuildOptions(maxTokens: 64));

        Console.WriteLine($"  -> {result.Content?.Trim()}\n");
    }

    // 4. ConditionalNode: route to one branch based on the input.
    private static async Task DemoConditionalNodeAsync(LM model)
    {
        Console.WriteLine("[4] ConditionalNode (route by keyword):");

        var techExpert = Agent.CreateBuilder(model)
            .WithPersona("TechExpert")
            .WithInstruction("Reply with one short technical answer.")
            .Build();

        var bizExpert = Agent.CreateBuilder(model)
            .WithPersona("BizExpert")
            .WithInstruction("Reply with one short business answer.")
            .Build();

        var graph = new ConditionalNode("route",
            selector: ctx => ctx.Input.Contains("api", StringComparison.OrdinalIgnoreCase)
                ? "tech"
                : "biz",
            branches: new Dictionary<string, IOrchestrationNode>
            {
                ["tech"] = new AgentNode("tech", techExpert),
                ["biz"] = new AgentNode("biz", bizExpert)
            });

        var orchestrator = new GraphOrchestrator(graph);
        var result = await orchestrator.ExecuteAsync(
            "How should I version a public API?",
            BuildOptions(maxTokens: 48));

        Console.WriteLine($"  -> {result.Content?.Trim()}\n");
    }

    // 5. Custom IOrchestrationNode for non-agent work (string transform).
    private static async Task DemoCustomNodeAsync(LM model)
    {
        Console.WriteLine("[5] Custom UpperCaseNode (no agent, just a transform):");

        var graph = new SequentialNode("transform",
            new UpperCaseNode());

        var orchestrator = new GraphOrchestrator(graph);
        var result = await orchestrator.ExecuteAsync("hello world");

        Console.WriteLine($"  -> {result.Content}\n");
    }

    // 6. The headline shape: Sequential[classify, Conditional[tech|biz], Parallel[style, facts]]
    private static async Task DemoNestedGraphAsync(LM model)
    {
        Console.WriteLine("[6] Nested graph (classify -> route -> parallel review):");

        var classifier = Agent.CreateBuilder(model)
            .WithPersona("Classifier")
            .WithInstruction("Reply with exactly one word: 'tech' or 'biz'.")
            .Build();

        var techExpert = Agent.CreateBuilder(model)
            .WithPersona("TechExpert")
            .WithInstruction("Write one short technical paragraph.")
            .Build();

        var bizExpert = Agent.CreateBuilder(model)
            .WithPersona("BizExpert")
            .WithInstruction("Write one short business paragraph.")
            .Build();

        var styleReviewer = Agent.CreateBuilder(model)
            .WithPersona("StyleReviewer")
            .WithInstruction("Suggest one concise style improvement.")
            .Build();

        var factChecker = Agent.CreateBuilder(model)
            .WithPersona("FactChecker")
            .WithInstruction("Point out one possible factual issue (or 'OK').")
            .Build();

        var graph = new SequentialNode("flow",
            new AgentNode("classify", classifier),
            new ConditionalNode("route",
                selector: ctx => ctx.Input.Contains("tech", StringComparison.OrdinalIgnoreCase)
                    ? "tech"
                    : "biz",
                branches: new Dictionary<string, IOrchestrationNode>
                {
                    ["tech"] = new AgentNode("tech", techExpert),
                    ["biz"] = new AgentNode("biz", bizExpert)
                }),
            new ParallelNode("review", new IOrchestrationNode[]
            {
                new AgentNode("style", styleReviewer),
                new AgentNode("facts", factChecker)
            }));

        var orchestrator = new GraphOrchestrator(graph);

        var result = await orchestrator.ExecuteAsync(
            "How should we version our public API?",
            BuildOptions(maxTokens: 192));

        foreach (var step in result.AgentResults)
        {
            Console.WriteLine($"  step : {step.AgentName} ({step.Status})");
        }

        Console.WriteLine();
        Console.WriteLine("Final aggregated review:");
        Console.WriteLine(result.Content?.Trim());
        Console.WriteLine();
    }

    private static OrchestrationOptions BuildOptions(int maxTokens)
    {
        return new OrchestrationOptions
        {
            SamplingMode = new GreedyDecoding(),
            MaxCompletionTokens = maxTokens,
            ReasoningLevel = ReasoningLevel.None,
            StopOnFailure = true
        };
    }
}

// Custom node implementation: any synchronous or asynchronous transformation
// you want to interleave with agent calls inside a graph.
internal sealed class UpperCaseNode : IOrchestrationNode
{
    public string Name => "uppercase";

    public Task<NodeResult> InvokeAsync(NodeContext context, CancellationToken cancellationToken)
    {
        string output = context.Input?.ToUpperInvariant() ?? string.Empty;
        context.Orchestration.AddTrace($"upper-cased {context.Input?.Length ?? 0} chars");
        return Task.FromResult(NodeResult.Success(output));
    }
}

Run it:

dotnet run -c Release

The first invocation downloads the model into the LM-Kit cache and may take a few minutes. Subsequent runs reuse the cached weights.


🧱 What Each Piece Does

IOrchestrationNode

Every node implements one method:

public interface IOrchestrationNode
{
    string Name { get; }
    Task<NodeResult> InvokeAsync(NodeContext context, CancellationToken cancellationToken);
}

NodeContext carries the per-call Input, the shared OrchestrationContext (where every node records its AgentExecutionResult, traces, and shared state), and the active OrchestrationOptions (so child nodes inherit MaxCompletionTokens, ReasoningLevel, sampling, etc.).

NodeResult returns the output that downstream nodes consume as their input, plus the underlying AgentExecutionResult (when the node wraps an agent), an early-stop signal, and any error.

AgentNode, SequentialNode, ParallelNode, ConditionalNode

  • AgentNode invokes a single Agent with the current input.
  • SequentialNode runs its children in order, piping each output into the next input.
  • ParallelNode fans the same input out to its children concurrently. An optional aggregator delegate merges results, and an optional maxParallelism cap throttles concurrency. The shared OrchestrationContext is parallel-safe.
  • ConditionalNode selects exactly one branch using a caller-supplied selector. The selector is awaited, so it can do asynchronous routing (for example, consulting a router-agent's classification before dispatching). A defaultBranch parameter handles unmatched routes; without one, an unknown route fails the node loudly.

GraphOrchestrator

A regular IOrchestrator, so it accepts the same OrchestrationOptions, supports streaming through OrchestrationOptions.StreamHandler, and emits the same ActivitySource spans as the prebuilt orchestrators. The graph result exposes per-node AgentResults (with status, content, and inference count) for post-run inspection.


⚙️ Per-Orchestration Options Flow Through the Graph

Settings on OrchestrationOptions propagate uniformly to every agent in the graph, including delegated workers in nested SupervisorOrchestrator nodes:

var options = new OrchestrationOptions
{
    SamplingMode = new GreedyDecoding(),
    MaxCompletionTokens = 256,
    ReasoningLevel = ReasoningLevel.None,
    StopOnFailure = true
};

Every AgentNode derives its per-agent AgentExecutionOptions from the shared OrchestrationOptions via the same single-source-of-truth helper used by the prebuilt orchestrators, so behavior is identical to a PipelineOrchestrator or ParallelOrchestrator run.


🔭 Observability

GraphOrchestrator emits the standard LMKit.Agents distributed-trace activities:

  • orchestration.execute for the whole graph run.
  • agent.execute for each AgentNode invocation.
  • agent.delegate if a node hosts a SupervisorOrchestrator that delegates.

Subscribe a standard System.Diagnostics.ActivityListener or wire the source into OpenTelemetry. See the Distributed Tracing how-to.


📚 When to Pick a Graph vs. a Prebuilt Orchestrator

Use case Pick
Linear pipeline of stages PipelineOrchestrator
Fan-out to parallel agents ParallelOrchestrator
Pick one agent per request based on input RouterOrchestrator
Supervisor LLM dynamically delegating to workers via delegate_to_agent SupervisorOrchestrator
Anything else (nested patterns, custom nodes, transformation steps interleaved with agents) GraphOrchestrator

The graph layer is purely additive. There is no migration cost. Adopt it where it pays off.

Share