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
| Method | Effect |
|---|---|
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.
- Exactly one valid edge — taken automatically. An edge is
valid if it is unconditional (
Then) or if its condition (ThenIf) evaluates totruefor the previous output. - 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. - 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 example | Last output | DeserializeOutput |
|---|---|---|
Verify returns "RESOLVED — invoice corrected" | non-JSON prose | throws JsonException |
Escalate returns a typed JSON object | valid JSON | succeeds |
MaxLoops hits at any non-JSON node | whatever that node emitted | usually throws |
no_valid_edge at Triage | "billing" | throws |
Three ways to defend against this
- Use
GraphAdmin<TInput, string>and parse in app code. You keep the freedom totry/catchand decide what to do with a non-JSON output (return a default, retry, escalate to human). - Make every potential terminal agent return
TOutput-shaped JSON. Wrap the agents that genuinely terminate the graph asAgent<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 aSupportReplycast. - Subscribe to
GraphTerminatedEvent. Inspect theReasonfield; 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
- Linear pipeline → Sequential.
- Open-ended collaboration → Group chat.
- Embarrassingly parallel → Parallel.
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
MaxLoopsandAdminOptions.MaxBudgetUsdfor production.