| | | 1 | | using System.Reflection; |
| | | 2 | | |
| | | 3 | | using Microsoft.Extensions.AI; |
| | | 4 | | |
| | | 5 | | using NexusLabs.Needlr.AgentFramework; |
| | | 6 | | |
| | | 7 | | namespace 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> |
| | | 13 | | internal 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 | | { |
| | 73 | 26 | | if (!topology.OutgoingEdgesBySource.TryGetValue(sourceType, out var edges) || edges.Count == 0) |
| | 1 | 27 | | return []; |
| | | 28 | | |
| | 72 | 29 | | var routingMode = topology.EffectiveRoutingModes.GetValueOrDefault(sourceType, topology.GraphRoutingMode); |
| | | 30 | | |
| | 72 | 31 | | if (routingMode == GraphRoutingMode.LlmChoice) |
| | | 32 | | { |
| | 6 | 33 | | return await ResolveLlmChoiceAsync(sourceType, edges, upstreamOutput, routingChatClient, cancellationToken); |
| | | 34 | | } |
| | | 35 | | |
| | 66 | 36 | | var matchingEdges = new List<GraphEdgeDetail>(); |
| | 331 | 37 | | foreach (var edge in edges) |
| | | 38 | | { |
| | 100 | 39 | | if (edge.Condition is null) |
| | | 40 | | { |
| | 62 | 41 | | matchingEdges.Add(edge); |
| | 62 | 42 | | continue; |
| | | 43 | | } |
| | | 44 | | |
| | 38 | 45 | | if (EvaluateCondition(sourceType, edge.Condition, upstreamOutput)) |
| | | 46 | | { |
| | 20 | 47 | | matchingEdges.Add(edge); |
| | | 48 | | } |
| | | 49 | | } |
| | | 50 | | |
| | | 51 | | switch (routingMode) |
| | | 52 | | { |
| | | 53 | | case GraphRoutingMode.Deterministic: |
| | | 54 | | case GraphRoutingMode.AllMatching: |
| | 54 | 55 | | return matchingEdges; |
| | | 56 | | |
| | | 57 | | case GraphRoutingMode.FirstMatching: |
| | 6 | 58 | | return matchingEdges.Count > 0 ? [matchingEdges[0]] : []; |
| | | 59 | | |
| | | 60 | | case GraphRoutingMode.ExclusiveChoice: |
| | 5 | 61 | | if (matchingEdges.Count == 0) |
| | | 62 | | { |
| | 3 | 63 | | throw new InvalidOperationException( |
| | 3 | 64 | | $"ExclusiveChoice routing on '{sourceType.Name}': no edge condition matched. " + |
| | 3 | 65 | | $"Exactly one must match."); |
| | | 66 | | } |
| | | 67 | | |
| | 2 | 68 | | if (matchingEdges.Count > 1) |
| | | 69 | | { |
| | 3 | 70 | | var names = string.Join(", ", matchingEdges.Select(e => e.Target.Name)); |
| | 1 | 71 | | throw new InvalidOperationException( |
| | 1 | 72 | | $"ExclusiveChoice routing on '{sourceType.Name}': {matchingEdges.Count} edges matched " + |
| | 1 | 73 | | $"({names}). Exactly one must match."); |
| | | 74 | | } |
| | | 75 | | |
| | 1 | 76 | | return matchingEdges; |
| | | 77 | | |
| | | 78 | | default: |
| | 0 | 79 | | return matchingEdges; |
| | | 80 | | } |
| | 67 | 81 | | } |
| | | 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 | | { |
| | 6 | 90 | | if (chatClient is null) |
| | | 91 | | { |
| | 1 | 92 | | throw new InvalidOperationException( |
| | 1 | 93 | | $"LlmChoice routing on '{sourceType.Name}' requires an IChatClient. " + |
| | 1 | 94 | | $"Ensure the agent framework is configured with a chat client via " + |
| | 1 | 95 | | $"UsingAgentFramework(af => af.Configure(opts => opts.ChatClientFactory = ...))."); |
| | | 96 | | } |
| | | 97 | | |
| | 15 | 98 | | var conditionalEdges = edges.Where(e => e.Condition is not null).ToList(); |
| | 5 | 99 | | if (conditionalEdges.Count == 0) |
| | | 100 | | { |
| | 0 | 101 | | return edges.ToList(); |
| | | 102 | | } |
| | | 103 | | |
| | 5 | 104 | | var options = string.Join("\n", conditionalEdges.Select( |
| | 15 | 105 | | (e, i) => $" {i + 1}. {e.Condition} → {e.Target.Name}")); |
| | | 106 | | |
| | 5 | 107 | | var routingPrompt = $""" |
| | 5 | 108 | | You are a routing agent. Based on the input below, choose which route to take. |
| | 5 | 109 | | |
| | 5 | 110 | | Input: |
| | 5 | 111 | | {upstreamOutput} |
| | 5 | 112 | | |
| | 5 | 113 | | Available routes: |
| | 5 | 114 | | {options} |
| | 5 | 115 | | |
| | 5 | 116 | | Respond with ONLY the number of the route you choose (e.g., "1" or "2"). Nothing else. |
| | 5 | 117 | | """; |
| | | 118 | | |
| | 5 | 119 | | var response = await chatClient.GetResponseAsync( |
| | 5 | 120 | | [new ChatMessage(ChatRole.User, routingPrompt)], |
| | 5 | 121 | | cancellationToken: cancellationToken); |
| | | 122 | | |
| | 5 | 123 | | var chosenText = response.Text?.Trim() ?? string.Empty; |
| | | 124 | | |
| | 5 | 125 | | GraphEdgeDetail? chosen = null; |
| | | 126 | | |
| | 5 | 127 | | if (int.TryParse(chosenText, out var choiceIndex) |
| | 5 | 128 | | && choiceIndex >= 1 |
| | 5 | 129 | | && choiceIndex <= conditionalEdges.Count) |
| | | 130 | | { |
| | 1 | 131 | | chosen = conditionalEdges[choiceIndex - 1]; |
| | | 132 | | } |
| | | 133 | | else |
| | | 134 | | { |
| | 4 | 135 | | chosen = conditionalEdges |
| | 10 | 136 | | .FirstOrDefault(e => string.Equals(e.Condition!, chosenText, StringComparison.OrdinalIgnoreCase)); |
| | | 137 | | } |
| | | 138 | | |
| | 5 | 139 | | if (chosen is null) |
| | | 140 | | { |
| | 3 | 141 | | var unconditional = edges.Where(e => e.Condition is null).ToList(); |
| | 1 | 142 | | return unconditional.Count > 0 ? unconditional : [conditionalEdges[0]]; |
| | | 143 | | } |
| | | 144 | | |
| | 4 | 145 | | var result = new List<GraphEdgeDetail> { chosen }; |
| | 12 | 146 | | result.AddRange(edges.Where(e => e.Condition is null)); |
| | 4 | 147 | | return result; |
| | 5 | 148 | | } |
| | | 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 | | { |
| | 45 | 156 | | var method = sourceType.GetMethod( |
| | 45 | 157 | | conditionMethodName, |
| | 45 | 158 | | BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic, |
| | 45 | 159 | | null, |
| | 45 | 160 | | [typeof(object)], |
| | 45 | 161 | | null); |
| | | 162 | | |
| | 45 | 163 | | if (method is null) |
| | | 164 | | { |
| | 1 | 165 | | method = sourceType.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic) |
| | 1 | 166 | | .FirstOrDefault(m => m.Name == conditionMethodName && m.GetParameters().Length == 1); |
| | | 167 | | } |
| | | 168 | | |
| | 45 | 169 | | if (method is null || method.ReturnType != typeof(bool)) |
| | | 170 | | { |
| | 2 | 171 | | throw new InvalidOperationException( |
| | 2 | 172 | | $"Condition '{conditionMethodName}' on '{sourceType.Name}' must be a static method " + |
| | 2 | 173 | | $"with signature 'static bool {conditionMethodName}(object? upstreamOutput)'."); |
| | | 174 | | } |
| | | 175 | | |
| | 43 | 176 | | return (bool)method.Invoke(null, [upstreamOutput])!; |
| | | 177 | | } |
| | | 178 | | } |