| | | 1 | | using System.Diagnostics.CodeAnalysis; |
| | | 2 | | using System.Reflection; |
| | | 3 | | using Microsoft.Agents.AI; |
| | | 4 | | using Microsoft.Agents.AI.Workflows; |
| | | 5 | | |
| | | 6 | | namespace NexusLabs.Needlr.AgentFramework; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Default implementation of <see cref="IWorkflowFactory"/> that assembles MAF workflows from |
| | | 10 | | /// topology declared via <see cref="AgentHandoffsToAttribute"/> and |
| | | 11 | | /// <see cref="AgentGroupChatMemberAttribute"/>. |
| | | 12 | | /// </summary> |
| | | 13 | | /// <remarks> |
| | | 14 | | /// When the source generator bootstrap is registered (i.e., the generator package is included |
| | | 15 | | /// and <c>UsingAgentFramework()</c> detected a <c>[ModuleInitializer]</c>-emitted registration), |
| | | 16 | | /// topology data is read from the compile-time registry for zero-allocation discovery. |
| | | 17 | | /// When no bootstrap data is available, topology is discovered via reflection at workflow |
| | | 18 | | /// creation time; that path is annotated <see cref="RequiresUnreferencedCodeAttribute"/>. |
| | | 19 | | /// </remarks> |
| | | 20 | | internal sealed class WorkflowFactory : IWorkflowFactory |
| | | 21 | | { |
| | | 22 | | private readonly IAgentFactory _agentFactory; |
| | | 23 | | |
| | 21 | 24 | | public WorkflowFactory(IAgentFactory agentFactory) |
| | | 25 | | { |
| | 21 | 26 | | ArgumentNullException.ThrowIfNull(agentFactory); |
| | 21 | 27 | | _agentFactory = agentFactory; |
| | 21 | 28 | | } |
| | | 29 | | |
| | | 30 | | /// <inheritdoc/> |
| | | 31 | | public Workflow CreateHandoffWorkflow<TInitialAgent>() where TInitialAgent : class |
| | | 32 | | { |
| | 9 | 33 | | var targets = ResolveHandoffTargets(typeof(TInitialAgent)); |
| | 8 | 34 | | var initialAgent = _agentFactory.CreateAgent<TInitialAgent>(); |
| | 8 | 35 | | return BuildHandoff(initialAgent, targets); |
| | | 36 | | } |
| | | 37 | | |
| | | 38 | | /// <inheritdoc/> |
| | | 39 | | public Workflow CreateGroupChatWorkflow(string groupName, int maxIterations = 10) |
| | | 40 | | { |
| | 8 | 41 | | ArgumentException.ThrowIfNullOrWhiteSpace(groupName); |
| | | 42 | | |
| | 8 | 43 | | var memberTypes = ResolveGroupChatMembers(groupName); |
| | | 44 | | |
| | 8 | 45 | | if (memberTypes.Count < 2) |
| | | 46 | | { |
| | 2 | 47 | | throw new InvalidOperationException( |
| | 2 | 48 | | $"CreateGroupChatWorkflow(\"{groupName}\") failed: {memberTypes.Count} agent(s) are " + |
| | 2 | 49 | | $"registered as members of group \"{groupName}\". At least two are required. " + |
| | 2 | 50 | | $"Add [AgentGroupChatMember(\"{groupName}\")] to the agent classes and ensure their " + |
| | 2 | 51 | | $"assemblies are scanned."); |
| | | 52 | | } |
| | | 53 | | |
| | 18 | 54 | | var agents = memberTypes.Select(t => _agentFactory.CreateAgent(t.Name)).ToList(); |
| | | 55 | | |
| | 6 | 56 | | var conditions = BuildTerminationConditions(memberTypes); |
| | | 57 | | |
| | 6 | 58 | | Func<IReadOnlyList<AIAgent>, RoundRobinGroupChatManager> managerFactory = conditions.Count > 0 |
| | 0 | 59 | | ? a => new RoundRobinGroupChatManager(a, ShouldTerminateAsync(conditions)) { MaximumIterationCount = maxIter |
| | 6 | 60 | | : a => new RoundRobinGroupChatManager(a) { MaximumIterationCount = maxIterations }; |
| | | 61 | | |
| | 6 | 62 | | return AgentWorkflowBuilder |
| | 6 | 63 | | .CreateGroupChatBuilderWith(managerFactory) |
| | 6 | 64 | | .AddParticipants(agents) |
| | 6 | 65 | | .Build(); |
| | | 66 | | } |
| | | 67 | | |
| | | 68 | | /// <inheritdoc/> |
| | | 69 | | public Workflow CreateSequentialWorkflow(params AIAgent[] agents) |
| | | 70 | | { |
| | 0 | 71 | | ArgumentNullException.ThrowIfNull(agents); |
| | | 72 | | |
| | 0 | 73 | | if (agents.Length == 0) |
| | 0 | 74 | | throw new ArgumentException("At least one agent is required.", nameof(agents)); |
| | | 75 | | |
| | 0 | 76 | | return AgentWorkflowBuilder.BuildSequential(agents); |
| | | 77 | | } |
| | | 78 | | |
| | | 79 | | /// <inheritdoc/> |
| | | 80 | | public Workflow CreateSequentialWorkflow(string pipelineName) |
| | | 81 | | { |
| | 7 | 82 | | ArgumentException.ThrowIfNullOrWhiteSpace(pipelineName); |
| | | 83 | | |
| | 7 | 84 | | var memberTypes = ResolveSequentialMembers(pipelineName); |
| | 24 | 85 | | var agents = memberTypes.Select(t => _agentFactory.CreateAgent(t.Name)).ToArray(); |
| | 6 | 86 | | return AgentWorkflowBuilder.BuildSequential(agents); |
| | | 87 | | } |
| | | 88 | | |
| | | 89 | | [UnconditionalSuppressMessage("TrimAnalysis", "IL2026", Justification = "Reflection fallback is unreachable in AOT b |
| | | 90 | | private IReadOnlyList<(Type TargetType, string? HandoffReason)> ResolveHandoffTargets(Type initialAgentType) |
| | | 91 | | { |
| | 9 | 92 | | if (AgentFrameworkGeneratedBootstrap.TryGetHandoffTopology(out var provider)) |
| | | 93 | | { |
| | 9 | 94 | | var topology = provider(); |
| | 9 | 95 | | if (topology.TryGetValue(initialAgentType, out var targets)) |
| | 2 | 96 | | return targets; |
| | | 97 | | |
| | | 98 | | // Type is not in the bootstrap topology — it may be from an assembly that didn't run the |
| | | 99 | | // generator. Fall back to reflection so multi-assembly and test scenarios work correctly. |
| | 7 | 100 | | return ResolveHandoffTargetsViaReflection(initialAgentType); |
| | | 101 | | } |
| | | 102 | | |
| | 0 | 103 | | return ResolveHandoffTargetsViaReflection(initialAgentType); |
| | | 104 | | } |
| | | 105 | | |
| | | 106 | | [RequiresUnreferencedCode("Reflection-based topology discovery may not work after trimming. Use the source generator |
| | | 107 | | private static IReadOnlyList<(Type TargetType, string? HandoffReason)> ResolveHandoffTargetsViaReflection(Type initi |
| | | 108 | | { |
| | 7 | 109 | | var attrs = initialAgentType.GetCustomAttributes<AgentHandoffsToAttribute>().ToList(); |
| | | 110 | | |
| | 7 | 111 | | if (attrs.Count == 0) |
| | | 112 | | { |
| | 1 | 113 | | throw new InvalidOperationException( |
| | 1 | 114 | | $"CreateHandoffWorkflow<{initialAgentType.Name}>() failed: {initialAgentType.Name} has no " + |
| | 1 | 115 | | $"[AgentHandoffsTo] attributes. Declare at least one " + |
| | 1 | 116 | | $"[AgentHandoffsTo(typeof(TargetAgent))] on {initialAgentType.Name} to specify its handoff targets."); |
| | | 117 | | } |
| | | 118 | | |
| | 6 | 119 | | return attrs |
| | 12 | 120 | | .Select(a => (a.TargetAgentType, a.HandoffReason)) |
| | 6 | 121 | | .ToList() |
| | 6 | 122 | | .AsReadOnly(); |
| | | 123 | | } |
| | | 124 | | |
| | | 125 | | [UnconditionalSuppressMessage("TrimAnalysis", "IL2026", Justification = "Reflection fallback is unreachable in AOT b |
| | | 126 | | private IReadOnlyList<Type> ResolveGroupChatMembers(string groupName) |
| | | 127 | | { |
| | 8 | 128 | | if (AgentFrameworkGeneratedBootstrap.TryGetGroupChatGroups(out var provider)) |
| | | 129 | | { |
| | 8 | 130 | | var groups = provider(); |
| | 8 | 131 | | if (groups.TryGetValue(groupName, out var members)) |
| | 2 | 132 | | return members; |
| | | 133 | | |
| | | 134 | | // Group not in the bootstrap — may be from an assembly that didn't run the generator. |
| | | 135 | | // Fall back to reflection so multi-assembly and test scenarios work correctly. |
| | 6 | 136 | | return ResolveGroupChatMembersViaReflection(groupName); |
| | | 137 | | } |
| | | 138 | | |
| | 0 | 139 | | return ResolveGroupChatMembersViaReflection(groupName); |
| | | 140 | | } |
| | | 141 | | |
| | | 142 | | [RequiresUnreferencedCode("Reflection-based group chat discovery may not work after trimming. Use the source generat |
| | | 143 | | private static IReadOnlyList<Type> ResolveGroupChatMembersViaReflection(string groupName) |
| | | 144 | | { |
| | 6 | 145 | | return AppDomain.CurrentDomain.GetAssemblies() |
| | 6 | 146 | | .SelectMany(a => |
| | 6 | 147 | | { |
| | 461 | 148 | | try { return a.GetTypes(); } |
| | 0 | 149 | | catch { return []; } |
| | 461 | 150 | | }) |
| | 85305 | 151 | | .Where(t => t.GetCustomAttributes<AgentGroupChatMemberAttribute>() |
| | 85347 | 152 | | .Any(attr => string.Equals(attr.GroupName, groupName, StringComparison.Ordinal))) |
| | 6 | 153 | | .ToList() |
| | 6 | 154 | | .AsReadOnly(); |
| | | 155 | | } |
| | | 156 | | |
| | | 157 | | [UnconditionalSuppressMessage("TrimAnalysis", "IL2026", Justification = "Reflection fallback is unreachable in AOT b |
| | | 158 | | private static IReadOnlyList<Type> ResolveSequentialMembers(string pipelineName) |
| | | 159 | | { |
| | 7 | 160 | | if (AgentFrameworkGeneratedBootstrap.TryGetSequentialTopology(out var provider)) |
| | | 161 | | { |
| | 7 | 162 | | var topology = provider(); |
| | 7 | 163 | | if (topology.TryGetValue(pipelineName, out var members) && members.Count > 0) |
| | 2 | 164 | | return members; |
| | | 165 | | |
| | 5 | 166 | | return ResolveSequentialMembersViaReflection(pipelineName); |
| | | 167 | | } |
| | | 168 | | |
| | 0 | 169 | | return ResolveSequentialMembersViaReflection(pipelineName); |
| | | 170 | | } |
| | | 171 | | |
| | | 172 | | [RequiresUnreferencedCode("Reflection-based sequential pipeline discovery may not work after trimming. Use the sourc |
| | | 173 | | private static IReadOnlyList<Type> ResolveSequentialMembersViaReflection(string pipelineName) |
| | | 174 | | { |
| | 5 | 175 | | var members = AppDomain.CurrentDomain.GetAssemblies() |
| | 5 | 176 | | .SelectMany(a => |
| | 5 | 177 | | { |
| | 410 | 178 | | try { return a.GetTypes(); } |
| | 0 | 179 | | catch { return []; } |
| | 410 | 180 | | }) |
| | 73399 | 181 | | .SelectMany(t => t.GetCustomAttributes<AgentSequenceMemberAttribute>() |
| | 15 | 182 | | .Where(attr => string.Equals(attr.PipelineName, pipelineName, StringComparison.Ordinal)) |
| | 73411 | 183 | | .Select(attr => (Type: t, attr.Order))) |
| | 12 | 184 | | .OrderBy(x => x.Order) |
| | 12 | 185 | | .Select(x => x.Type) |
| | 5 | 186 | | .ToList() |
| | 5 | 187 | | .AsReadOnly(); |
| | | 188 | | |
| | 5 | 189 | | if (members.Count == 0) |
| | 1 | 190 | | throw new InvalidOperationException( |
| | 1 | 191 | | $"No agents found for sequential pipeline '{pipelineName}'. " + |
| | 1 | 192 | | $"Decorate agent classes with [AgentSequenceMember(\"{pipelineName}\", order)] and ensure their assembli |
| | | 193 | | |
| | 4 | 194 | | return members; |
| | | 195 | | } |
| | | 196 | | |
| | | 197 | | private Workflow BuildHandoff( |
| | | 198 | | AIAgent initialAgent, |
| | | 199 | | IReadOnlyList<(Type TargetType, string? HandoffReason)> targets) |
| | | 200 | | { |
| | 8 | 201 | | if (targets.Count == 0) |
| | | 202 | | { |
| | 0 | 203 | | throw new InvalidOperationException( |
| | 0 | 204 | | $"Cannot build handoff workflow for agent '{initialAgent.Name ?? initialAgent.Id}': no handoff targets f |
| | | 205 | | } |
| | | 206 | | |
| | 8 | 207 | | var targetPairs = targets |
| | 16 | 208 | | .Select(t => (_agentFactory.CreateAgent(t.TargetType.Name), t.HandoffReason)) |
| | 8 | 209 | | .ToArray(); |
| | | 210 | | |
| | 8 | 211 | | var builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent); |
| | | 212 | | |
| | 8 | 213 | | var withoutReason = targetPairs |
| | 16 | 214 | | .Where(t => string.IsNullOrEmpty(t.HandoffReason)) |
| | 16 | 215 | | .Select(t => t.Item1) |
| | 8 | 216 | | .ToArray(); |
| | | 217 | | |
| | 8 | 218 | | if (withoutReason.Length > 0) |
| | 8 | 219 | | builder.WithHandoffs(initialAgent, withoutReason); |
| | | 220 | | |
| | 32 | 221 | | foreach (var (target, reason) in targetPairs.Where(t => !string.IsNullOrEmpty(t.HandoffReason))) |
| | 0 | 222 | | builder.WithHandoff(initialAgent, target, reason!); |
| | | 223 | | |
| | 8 | 224 | | return builder.Build(); |
| | | 225 | | } |
| | | 226 | | |
| | | 227 | | private static IReadOnlyList<(string AgentName, IWorkflowTerminationCondition Condition)> BuildTerminationConditions |
| | | 228 | | IReadOnlyList<Type> memberTypes) |
| | | 229 | | { |
| | 6 | 230 | | var result = new List<(string, IWorkflowTerminationCondition)>(); |
| | 36 | 231 | | foreach (var type in memberTypes) |
| | | 232 | | { |
| | 26 | 233 | | foreach (var attr in type.GetCustomAttributes<AgentTerminationConditionAttribute>()) |
| | | 234 | | { |
| | 1 | 235 | | var condition = (IWorkflowTerminationCondition)Activator.CreateInstance( |
| | 1 | 236 | | attr.ConditionType, attr.CtorArgs)!; |
| | 1 | 237 | | result.Add((type.Name, condition)); |
| | | 238 | | } |
| | | 239 | | } |
| | 6 | 240 | | return result; |
| | | 241 | | } |
| | | 242 | | |
| | | 243 | | private static Func<RoundRobinGroupChatManager, IEnumerable<Microsoft.Extensions.AI.ChatMessage>, CancellationToken, |
| | | 244 | | IReadOnlyList<(string AgentName, IWorkflowTerminationCondition Condition)> conditions) |
| | | 245 | | { |
| | 0 | 246 | | return (manager, history, ct) => |
| | 0 | 247 | | { |
| | 0 | 248 | | var historyList = history.ToList(); |
| | 0 | 249 | | if (historyList.Count == 0) |
| | 0 | 250 | | return ValueTask.FromResult(false); |
| | 0 | 251 | | |
| | 0 | 252 | | var lastMessage = historyList[^1]; |
| | 0 | 253 | | var agentId = lastMessage.AuthorName ?? string.Empty; |
| | 0 | 254 | | var responseText = lastMessage.Text ?? string.Empty; |
| | 0 | 255 | | var ctx = new TerminationContext |
| | 0 | 256 | | { |
| | 0 | 257 | | AgentId = agentId, |
| | 0 | 258 | | ResponseText = responseText, |
| | 0 | 259 | | TurnCount = historyList.Count, |
| | 0 | 260 | | ConversationHistory = historyList, |
| | 0 | 261 | | }; |
| | 0 | 262 | | |
| | 0 | 263 | | foreach (var (_, condition) in conditions) |
| | 0 | 264 | | { |
| | 0 | 265 | | if (condition.ShouldTerminate(ctx)) |
| | 0 | 266 | | return ValueTask.FromResult(true); |
| | 0 | 267 | | } |
| | 0 | 268 | | |
| | 0 | 269 | | return ValueTask.FromResult(false); |
| | 0 | 270 | | }; |
| | | 271 | | } |
| | | 272 | | } |