Skip to main content

Graph admin

GraphAdmin<TInput, TOutput> runs a directed graph of agents. You declare the nodes and edges. Branching is conditional or LLM-decided. This is the right pattern when control flow matters.

Topology of the example below

A customer-support triage flow:

The graph routes a customer message to one of three specialised handlers, runs a verification step, and either returns a resolved answer or escalates to a human-handler agent. It uses every builder construct — entry node, Branch, deterministic ThenIf, unconditional Then, and a cycle-free escalation path.

Example — customer-support triage

using LogicGrid.Core.Admins;
using LogicGrid.Core.Agents;
using LogicGrid.Core.Graph;
using LogicGrid.Core.Llm;

var llm = LlmClientBase.Ollama("llama3.2");

IAgent triage = new Agent<string>(
name: "Triage",
description: "Classifies the customer message.",
systemPrompt: "Reply with exactly one word: 'billing', 'technical', or 'general'.",
llm: llm);

IAgent billing = new Agent<string>(
name: "Billing",
description: "Answers billing questions (invoices, refunds, plans).",
systemPrompt: "Answer the billing question. Be specific and cite the policy.",
llm: llm);

IAgent technical = new Agent<string>(
name: "Technical",
description: "Diagnoses product issues and proposes fixes.",
systemPrompt: "Diagnose the issue and propose concrete steps the customer can try.",
llm: llm);

IAgent general = new Agent<string>(
name: "General",
description: "Handles general inquiries.",
systemPrompt: "Answer the inquiry concisely and point to relevant documentation.",
llm: llm);

IAgent verify = new Agent<string>(
name: "Verify",
description: "Confirms whether the previous response resolves the customer's request.",
systemPrompt: "Read the previous answer. Reply RESOLVED if it fully addresses the request. " +
"Otherwise reply UNRESOLVED on the first line and briefly state what is missing.",
llm: llm);

IAgent escalate = new Agent<string>(
name: "Escalate",
description: "Drafts a hand-off note to a human support agent.",
systemPrompt: "Summarise the conversation, the unresolved gap, and the customer's stated need. " +
"Format as a single paragraph for a human agent to pick up.",
llm: llm);

// Convention used below:
// - The verifier prefixes its output with RESOLVED or UNRESOLVED.
// - The graph uses a deterministic ThenIf to route to escalation
// when the verifier says UNRESOLVED.
// - Otherwise the verify node is terminal (no outgoing valid edge).

var graph = AgentGraphBuilder
.Start(triage)
.Branch(triage,
(billing, "billing question"),
(technical, "technical issue"),
(general, "general inquiry"))
.Then(billing, verify)
.Then(technical, verify)
.Then(general, verify)
.ThenIf(verify, escalate,
condition: lastOutput =>
lastOutput.TrimStart().StartsWith("UNRESOLVED",
StringComparison.OrdinalIgnoreCase),
label: "verifier could not confirm resolution")
.Terminal(escalate)
.Build();

var admin = new GraphAdmin<string, string>(
name: "SupportTriage",
llmClient: llm,
graph: graph,
options: new AdminOptions { MaxLoops = 8 });

var ctx = new AgentContext().WithLogging();
var reply = await admin.RunAsync(
input: "Hi, my last invoice charged me twice for the Pro plan. Can you check?");

Console.WriteLine($"\n{reply}");
09:50:01.118 [INF] Run started — admin: SupportTriage
09:50:02.420 [INF] [Triage] completed | output: billing
09:50:02.430 [INF] Loop 1 — selected next node: Billing (3 valid edges)
09:50:05.500 [INF] [Billing] completed | output: I can see two charges …
09:50:05.510 [INF] Loop 2 — edge Billing → Verify
09:50:07.901 [INF] [Verify] completed | output: RESOLVED.
09:50:07.910 [INF] Graph terminated at Verify — no valid outgoing edge
09:50:07.911 [INF] Run completed — 3 agents, 4 LLM calls | 6793ms

When the verifier instead replies UNRESOLVED — refund policy on annual plans is unclear, the deterministic ThenIf becomes the only valid edge and the run continues into Escalate for a clean hand-off.

Builder API

MethodEffect
Start(agent)Set the entry node. Required.
Then(from, to, label?)Add an unconditional edge.
ThenIf(from, to, condition, label?)Add a conditional edge — only valid when condition(lastOutput) is true.
Branch(from, params (to, label)[])Add multiple outgoing edges from one node. When more than one is valid at runtime, the admin's LLM chooses one based on the edge labels and the previous output.
Terminal(agent)Mark a node as terminal. Optional — nodes with no outgoing edges are terminal automatically.
Build()Build the immutable AgentGraph.

How the admin chooses the next node

GraphAdmin follows the standard pattern used by mainstream agent-graph frameworks (LangGraph, AutoGen, Semantic Kernel): single-path traversal with three resolution strategies. At each step the admin examines the outgoing edges of the current node and applies the rules below in order.

  1. Exactly one valid edge — taken automatically. An edge is valid if it is unconditional (Then) or if its condition (ThenIf) evaluates to true for the previous output.
  2. Multiple valid edges — the admin asks its LLM to choose one from the valid set, presented to the LLM by name and edge label. This is the "LLM router" pattern; it is always exactly one chosen edge per step, never several. For genuinely concurrent fan-out, use ParallelAdmin — either as a separate step or wrapped inside a custom node.
  3. No valid edges — the run terminates and the last output is returned.

A GraphTerminatedEvent is fired when traversal stops, with one of the reasons "terminal_node", "no_valid_edge", or "max_loops".

Cycles and termination

Cycles are allowed — that's how revise-loops work. AdminOptions.MaxLoops acts as the safety net. If the graph never reaches a terminal node, the admin returns the last output after MaxLoops agent selections and fires GraphTerminatedEvent with reason "max_loops".

Typed graph output

GraphAdmin<TInput, TOutput> is generic on TOutput. When TOutput is anything other than string, the admin always tries to deserialize the last agent's string output to your type as the final step:

private TOutput DeserializeOutput(string raw)
{
if (typeof(TOutput) == typeof(string))
return (TOutput)(object)raw;
return System.Text.Json.JsonSerializer.Deserialize<TOutput>(raw)!;
}

This path is taken regardless of why the graph stopped — terminal node reached, no valid outgoing edge, or MaxLoops hit. The framework treats "where did the graph stop?" as your concern, not its concern. So with a typed graph, the deserialization will throw JsonException if the agent that produced the last output didn't emit JSON matching TOutput.

Termination point in the triage exampleLast outputDeserializeOutput
Verify returns "RESOLVED — invoice corrected"non-JSON prosethrows JsonException
Escalate returns a typed JSON objectvalid JSONsucceeds
MaxLoops hits at any non-JSON nodewhatever that node emittedusually throws
no_valid_edge at Triage"billing"throws

Three ways to defend against this

  1. Use GraphAdmin<TInput, string> and parse in app code. You keep the freedom to try/catch and decide what to do with a non-JSON output (return a default, retry, escalate to human).
  2. Make every potential terminal agent return TOutput-shaped JSON. Wrap the agents that genuinely terminate the graph as Agent<TOutput> with prompts that emit matching JSON. Don't let an intermediate agent become the de-facto terminal — that's how you end up with "billing" flowing into a SupportReply cast.
  3. Subscribe to GraphTerminatedEvent. Inspect the Reason field; if it's "max_loops" or "no_valid_edge" you know the graph didn't end cleanly. Throw a higher-level exception or surface an "incomplete" sentinel from your application code before the cast even runs.

For most typed-graph use cases, option 2 is the cleanest: design each terminal node so its output is the typed result you actually want returned.

Use it when

  • The pipeline branches based on data or LLM decisions.
  • You need an explicit loop — revise → re-review → re-revise.
  • You want testable control flow that can be drawn on a whiteboard.

Don't use it when

Common pitfalls

  • Marking non-terminal nodes as terminal. Terminal(node) is a hint, not an override. If the node has outgoing edges that are valid at runtime, the admin can still take one. Mark only the nodes that should genuinely end the run.
  • Unbounded cycles. Revise-loops are a feature, but a misbehaving critic can keep them going forever. Set MaxLoops and AdminOptions.MaxBudgetUsd for production.