< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Workflows.GraphEdgeRouter
Assembly: NexusLabs.Needlr.AgentFramework.Workflows
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Workflows/GraphEdgeRouter.cs
Line coverage
97%
Covered lines: 77
Uncovered lines: 2
Coverable lines: 79
Total lines: 178
Line coverage: 97.4%
Branch coverage
85%
Covered branches: 42
Total branches: 49
Branch coverage: 85.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ResolveOutgoingEdgesAsync()95.65%232396%
ResolveLlmChoiceAsync()77.77%181897.05%
EvaluateCondition(...)75%88100%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Workflows/GraphEdgeRouter.cs

#LineLine coverage
 1using System.Reflection;
 2
 3using Microsoft.Extensions.AI;
 4
 5using NexusLabs.Needlr.AgentFramework;
 6
 7namespace NexusLabs.Needlr.AgentFramework.Workflows;
 8
 9/// <summary>
 10/// Evaluates edge conditions and enforces routing mode semantics to determine
 11/// which outgoing edges a node should follow after execution.
 12/// </summary>
 13internal sealed class GraphEdgeRouter
 14{
 15    /// <summary>
 16    /// Resolves which outgoing edges from a source node should be followed,
 17    /// based on the effective routing mode and edge conditions.
 18    /// </summary>
 19    public async Task<List<GraphEdgeDetail>> ResolveOutgoingEdgesAsync(
 20        Type sourceType,
 21        object? upstreamOutput,
 22        GraphTopology topology,
 23        IChatClient? routingChatClient,
 24        CancellationToken cancellationToken)
 25    {
 7326        if (!topology.OutgoingEdgesBySource.TryGetValue(sourceType, out var edges) || edges.Count == 0)
 127            return [];
 28
 7229        var routingMode = topology.EffectiveRoutingModes.GetValueOrDefault(sourceType, topology.GraphRoutingMode);
 30
 7231        if (routingMode == GraphRoutingMode.LlmChoice)
 32        {
 633            return await ResolveLlmChoiceAsync(sourceType, edges, upstreamOutput, routingChatClient, cancellationToken);
 34        }
 35
 6636        var matchingEdges = new List<GraphEdgeDetail>();
 33137        foreach (var edge in edges)
 38        {
 10039            if (edge.Condition is null)
 40            {
 6241                matchingEdges.Add(edge);
 6242                continue;
 43            }
 44
 3845            if (EvaluateCondition(sourceType, edge.Condition, upstreamOutput))
 46            {
 2047                matchingEdges.Add(edge);
 48            }
 49        }
 50
 51        switch (routingMode)
 52        {
 53            case GraphRoutingMode.Deterministic:
 54            case GraphRoutingMode.AllMatching:
 5455                return matchingEdges;
 56
 57            case GraphRoutingMode.FirstMatching:
 658                return matchingEdges.Count > 0 ? [matchingEdges[0]] : [];
 59
 60            case GraphRoutingMode.ExclusiveChoice:
 561                if (matchingEdges.Count == 0)
 62                {
 363                    throw new InvalidOperationException(
 364                        $"ExclusiveChoice routing on '{sourceType.Name}': no edge condition matched. " +
 365                        $"Exactly one must match.");
 66                }
 67
 268                if (matchingEdges.Count > 1)
 69                {
 370                    var names = string.Join(", ", matchingEdges.Select(e => e.Target.Name));
 171                    throw new InvalidOperationException(
 172                        $"ExclusiveChoice routing on '{sourceType.Name}': {matchingEdges.Count} edges matched " +
 173                        $"({names}). Exactly one must match.");
 74                }
 75
 176                return matchingEdges;
 77
 78            default:
 079                return matchingEdges;
 80        }
 6781    }
 82
 83    private static async Task<List<GraphEdgeDetail>> ResolveLlmChoiceAsync(
 84        Type sourceType,
 85        List<GraphEdgeDetail> edges,
 86        object? upstreamOutput,
 87        IChatClient? chatClient,
 88        CancellationToken cancellationToken)
 89    {
 690        if (chatClient is null)
 91        {
 192            throw new InvalidOperationException(
 193                $"LlmChoice routing on '{sourceType.Name}' requires an IChatClient. " +
 194                $"Ensure the agent framework is configured with a chat client via " +
 195                $"UsingAgentFramework(af => af.Configure(opts => opts.ChatClientFactory = ...)).");
 96        }
 97
 1598        var conditionalEdges = edges.Where(e => e.Condition is not null).ToList();
 599        if (conditionalEdges.Count == 0)
 100        {
 0101            return edges.ToList();
 102        }
 103
 5104        var options = string.Join("\n", conditionalEdges.Select(
 15105            (e, i) => $"  {i + 1}. {e.Condition} → {e.Target.Name}"));
 106
 5107        var routingPrompt = $"""
 5108            You are a routing agent. Based on the input below, choose which route to take.
 5109
 5110            Input:
 5111            {upstreamOutput}
 5112
 5113            Available routes:
 5114            {options}
 5115
 5116            Respond with ONLY the number of the route you choose (e.g., "1" or "2"). Nothing else.
 5117            """;
 118
 5119        var response = await chatClient.GetResponseAsync(
 5120            [new ChatMessage(ChatRole.User, routingPrompt)],
 5121            cancellationToken: cancellationToken);
 122
 5123        var chosenText = response.Text?.Trim() ?? string.Empty;
 124
 5125        GraphEdgeDetail? chosen = null;
 126
 5127        if (int.TryParse(chosenText, out var choiceIndex)
 5128            && choiceIndex >= 1
 5129            && choiceIndex <= conditionalEdges.Count)
 130        {
 1131            chosen = conditionalEdges[choiceIndex - 1];
 132        }
 133        else
 134        {
 4135            chosen = conditionalEdges
 10136                .FirstOrDefault(e => string.Equals(e.Condition!, chosenText, StringComparison.OrdinalIgnoreCase));
 137        }
 138
 5139        if (chosen is null)
 140        {
 3141            var unconditional = edges.Where(e => e.Condition is null).ToList();
 1142            return unconditional.Count > 0 ? unconditional : [conditionalEdges[0]];
 143        }
 144
 4145        var result = new List<GraphEdgeDetail> { chosen };
 12146        result.AddRange(edges.Where(e => e.Condition is null));
 4147        return result;
 5148    }
 149
 150    /// <summary>
 151    /// Evaluates a condition string by looking up a static method on the source
 152    /// agent type that accepts <c>object?</c> and returns <c>bool</c>.
 153    /// </summary>
 154    internal static bool EvaluateCondition(Type sourceType, string conditionMethodName, object? upstreamOutput)
 155    {
 45156        var method = sourceType.GetMethod(
 45157            conditionMethodName,
 45158            BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic,
 45159            null,
 45160            [typeof(object)],
 45161            null);
 162
 45163        if (method is null)
 164        {
 1165            method = sourceType.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic)
 1166                .FirstOrDefault(m => m.Name == conditionMethodName && m.GetParameters().Length == 1);
 167        }
 168
 45169        if (method is null || method.ReturnType != typeof(bool))
 170        {
 2171            throw new InvalidOperationException(
 2172                $"Condition '{conditionMethodName}' on '{sourceType.Name}' must be a static method " +
 2173                $"with signature 'static bool {conditionMethodName}(object? upstreamOutput)'.");
 174        }
 175
 43176        return (bool)method.Invoke(null, [upstreamOutput])!;
 177    }
 178}