Skip to content

Routing & Edges

Unconditional Edges

The simplest edge — always traversed:

csharp
.From("researcher").To("writer")

Fan-out and fan-in:

csharp
.From("triage").To("researcher", "factChecker")   // parallel
.From("researcher", "factChecker").To("writer")   // merge

Conditional Edges

Add conditions after .To(). All conditions listed here are fully serializable — they round-trip through ExportConfigJson() and JSON config files.

Basic Field Conditions

csharp
// Field equals / not equals
.From("triage").To("writer").WhenEquals("complexity", "simple")
.From("triage").To("researcher").WhenEquals("complexity", "complex")
.From("checker").To("retry").WhenNotEquals("status", "ok")

// Field exists / not exists
.From("analyzer").To("escalate").WhenExists("risk_flag")
.From("analyzer").To("complete").WhenNotExists("risk_flag")

// Numeric comparisons
.From("scorer").To("approve").WhenGreaterThan("score", 0.8)
.From("scorer").To("review").WhenLessThan("score", 0.8)

// String / collection contains (single value)
.From("classifier").To("urgent").WhenContains("tags", "urgent")

// Default fallback (fires when no other condition from this node matched)
.From("triage").To("general").AsDefault()

The field names refer to output keys from the source node (e.g., node.WithOutputKey("complexity")).


Compound Logic — And, Or, Not

Combine multiple conditions without dropping into a C# predicate. Use a static import for the cleanest syntax:

csharp
using static HPD.MultiAgent.Routing.Condition;
csharp
// Both conditions must be true
.From("triage").To("vip-billing")
    .When(And(
        Equals("intent", "billing"),
        Equals("tier", "VIP")
    ))

// Either condition must be true
.From("triage").To("escalate")
    .When(Or(
        Equals("status", "urgent"),
        GreaterThan("priority", 8)
    ))

// Negate a condition
.From("classifier").To("skip")
    .When(Not(Exists("summary")))

// Arbitrary nesting
.From("checker").To("approve")
    .When(And(
        Or(Equals("region", "US"), Equals("region", "EU")),
        Not(Equals("flagged", true))
    ))

And with an empty list returns true (vacuously). Or with an empty list returns false.

Constraint: Default cannot appear inside And/Or/Not. It is a graph-level routing concept, not a boolean sub-condition. The evaluator throws InvalidOperationException if it is nested.

JSON representation:

json
{
  "type": "And",
  "conditions": [
    { "type": "FieldEquals", "field": "intent", "value": "billing" },
    { "type": "FieldEquals", "field": "tier",   "value": "VIP"  }
  ]
}

Advanced String Conditions

csharp
// Starts / ends with
.From("router").To("billing").WhenStartsWith("intent", "billing/")
.From("router").To("billing").WhenEndsWith("code", "_billing")

// Regular expression
.From("classifier").To("affirm").WhenMatchesRegex("response", @"^(yes|sure|ok)$")
.From("classifier").To("affirm").WhenMatchesRegex("response", @"^yes$", RegexOptions.IgnoreCase)

// Empty / not empty (null, "", or whitespace all count as "empty")
.From("drafter").To("retry").WhenEmpty("draft")
.From("drafter").To("reviewer").WhenNotEmpty("draft")

ReDoS protection: Regex evaluation has a 50 ms timeout by default. A match that exceeds it is treated as false (non-match) — the workflow continues safely. The timeout is configurable:

csharp
HPDAgent.Graph.Core.Orchestration.ConditionEvaluator.RegexMatchTimeout = TimeSpan.FromMilliseconds(100);

Regex flags in JSON:

json
{ "type": "FieldMatchesRegex", "field": "intent", "value": "^billing", "regexOptions": "IgnoreCase" }
{ "type": "FieldMatchesRegex", "field": "body",   "value": "^line1$",  "regexOptions": "IgnoreCase,Multiline" }

Multi-Value Collection Conditions

Use these when a node outputs an array of tags or steps (e.g., AgentOutputMode.Structured):

csharp
// At least one of the given values must be present in the field array
.From("classifier").To("escalate")
    .WhenContainsAny("tags", "urgent", "escalate", "manager")

// All of the given values must be present in the field array
.From("checker").To("approve")
    .WhenContainsAll("required_steps", "verified", "payment_ok")

JSON:

json
{ "type": "FieldContainsAny", "field": "tags",           "value": ["urgent", "escalate", "manager"] }
{ "type": "FieldContainsAll", "field": "required_steps", "value": ["verified", "payment_ok"] }

These work correctly when the field value arrives as a JsonElement array (i.e., loaded from a JSON config file).


Non-Serializable Predicate Edges

For routing logic that genuinely cannot be expressed declaratively, a C# lambda escape hatch is available:

csharp
.From("scorer").To("approve").When(ctx => ctx.Get<double>("score") > 0.8 && ctx.HasKey("verified"))
.From("scorer").To("review").When(ctx => ctx.Get<double>("score") <= 0.8)

.When(predicate) edges are not serializable — they cannot be round-tripped via ExportConfigJson() or stored in a JSON config file. Prefer the declarative conditions above for any workflow that needs serialization.


Condition Tier Summary

TierAPIExampleSerializable?
BasicWhenEquals, WhenGreaterThan, WhenExists, WhenContains, …Single-field checksYes
AdvancedWhen(And/Or/Not), WhenMatchesRegex, WhenContainsAny, WhenEmpty, …Multi-field, patterns, arraysYes
Escape hatch.When(predicate)Arbitrary C# runtime logicNo

Router Agents

A router agent uses handoffs — tool calls — to pick the next node, rather than relying on static field conditions. Useful when the routing logic requires LLM judgment.

csharp
AgentWorkflow.Create()
    .AddRouterAgent("router", new AgentConfig
    {
        SystemInstructions = "Classify the request and route to the right team."
    })
        .WithHandoff("billing", "User has a billing or payment question")
        .WithHandoff("technical", "User has a technical or product question")
        .WithDefaultHandoff("general")   // Fallback if no handoff is called

    .AddAgent("billing", billingConfig)
    .AddAgent("technical", techConfig)
    .AddAgent("general", generalConfig)
    .BuildAsync()

The router gets a handoff_to_billing() and handoff_to_technical() tool. Whichever it calls determines the next node. WithDefaultHandoff sets a fallback edge for when no handoff is triggered.

You can also configure router options:

csharp
.AddRouterAgent("router", config)
    .WithHandoff("billing", "Billing questions")
    .Configure(node => node.WithTimeout(TimeSpan.FromSeconds(20)))

Type-Based Routing

When a node uses UnionOutput, route based on which type was matched:

csharp
.AddAgent("classifier", classifierConfig, node =>
    node.UnionOutput<SimpleAnswer, DetailedReport, EscalationRequest>())

.From("classifier").RouteByType()
    .When<SimpleAnswer>("respond")
    .When<DetailedReport>("format")
    .When<EscalationRequest>("escalate")
    .Default("respond")  // fallback

The matched_type output field (set automatically in Union mode) drives the routing.


Cyclic Graphs

Agents can loop back to earlier nodes for iterative refinement:

csharp
AgentWorkflow.Create()
    .WithMaxIterations(5)   // prevent infinite loops
    .AddAgent("drafter", drafterConfig)
    .AddAgent("reviewer", reviewerConfig)
    .From("drafter").To("reviewer")
    .From("reviewer").To("drafter").WhenEquals("verdict", "revise")
    .From("reviewer").To("publisher").WhenEquals("verdict", "approved")
    .BuildAsync()

Set WithMaxIterations() on cyclic graphs. The workflow stops if the limit is reached.

Released under the MIT License.