< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.WorkflowFactory
Assembly: NexusLabs.Needlr.AgentFramework
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/WorkflowFactory.cs
Line coverage
72%
Covered lines: 98
Uncovered lines: 38
Coverable lines: 136
Total lines: 272
Line coverage: 72%
Branch coverage
62%
Covered branches: 31
Total branches: 50
Branch coverage: 62%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/WorkflowFactory.cs

#LineLine coverage
 1using System.Diagnostics.CodeAnalysis;
 2using System.Reflection;
 3using Microsoft.Agents.AI;
 4using Microsoft.Agents.AI.Workflows;
 5
 6namespace 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>
 20internal sealed class WorkflowFactory : IWorkflowFactory
 21{
 22    private readonly IAgentFactory _agentFactory;
 23
 2124    public WorkflowFactory(IAgentFactory agentFactory)
 25    {
 2126        ArgumentNullException.ThrowIfNull(agentFactory);
 2127        _agentFactory = agentFactory;
 2128    }
 29
 30    /// <inheritdoc/>
 31    public Workflow CreateHandoffWorkflow<TInitialAgent>() where TInitialAgent : class
 32    {
 933        var targets = ResolveHandoffTargets(typeof(TInitialAgent));
 834        var initialAgent = _agentFactory.CreateAgent<TInitialAgent>();
 835        return BuildHandoff(initialAgent, targets);
 36    }
 37
 38    /// <inheritdoc/>
 39    public Workflow CreateGroupChatWorkflow(string groupName, int maxIterations = 10)
 40    {
 841        ArgumentException.ThrowIfNullOrWhiteSpace(groupName);
 42
 843        var memberTypes = ResolveGroupChatMembers(groupName);
 44
 845        if (memberTypes.Count < 2)
 46        {
 247            throw new InvalidOperationException(
 248                $"CreateGroupChatWorkflow(\"{groupName}\") failed: {memberTypes.Count} agent(s) are " +
 249                $"registered as members of group \"{groupName}\". At least two are required. " +
 250                $"Add [AgentGroupChatMember(\"{groupName}\")] to the agent classes and ensure their " +
 251                $"assemblies are scanned.");
 52        }
 53
 1854        var agents = memberTypes.Select(t => _agentFactory.CreateAgent(t.Name)).ToList();
 55
 656        var conditions = BuildTerminationConditions(memberTypes);
 57
 658        Func<IReadOnlyList<AIAgent>, RoundRobinGroupChatManager> managerFactory = conditions.Count > 0
 059            ? a => new RoundRobinGroupChatManager(a, ShouldTerminateAsync(conditions)) { MaximumIterationCount = maxIter
 660            : a => new RoundRobinGroupChatManager(a) { MaximumIterationCount = maxIterations };
 61
 662        return AgentWorkflowBuilder
 663            .CreateGroupChatBuilderWith(managerFactory)
 664            .AddParticipants(agents)
 665            .Build();
 66    }
 67
 68    /// <inheritdoc/>
 69    public Workflow CreateSequentialWorkflow(params AIAgent[] agents)
 70    {
 071        ArgumentNullException.ThrowIfNull(agents);
 72
 073        if (agents.Length == 0)
 074            throw new ArgumentException("At least one agent is required.", nameof(agents));
 75
 076        return AgentWorkflowBuilder.BuildSequential(agents);
 77    }
 78
 79    /// <inheritdoc/>
 80    public Workflow CreateSequentialWorkflow(string pipelineName)
 81    {
 782        ArgumentException.ThrowIfNullOrWhiteSpace(pipelineName);
 83
 784        var memberTypes = ResolveSequentialMembers(pipelineName);
 2485        var agents = memberTypes.Select(t => _agentFactory.CreateAgent(t.Name)).ToArray();
 686        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    {
 992        if (AgentFrameworkGeneratedBootstrap.TryGetHandoffTopology(out var provider))
 93        {
 994            var topology = provider();
 995            if (topology.TryGetValue(initialAgentType, out var targets))
 296                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.
 7100            return ResolveHandoffTargetsViaReflection(initialAgentType);
 101        }
 102
 0103        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    {
 7109        var attrs = initialAgentType.GetCustomAttributes<AgentHandoffsToAttribute>().ToList();
 110
 7111        if (attrs.Count == 0)
 112        {
 1113            throw new InvalidOperationException(
 1114                $"CreateHandoffWorkflow<{initialAgentType.Name}>() failed: {initialAgentType.Name} has no " +
 1115                $"[AgentHandoffsTo] attributes. Declare at least one " +
 1116                $"[AgentHandoffsTo(typeof(TargetAgent))] on {initialAgentType.Name} to specify its handoff targets.");
 117        }
 118
 6119        return attrs
 12120            .Select(a => (a.TargetAgentType, a.HandoffReason))
 6121            .ToList()
 6122            .AsReadOnly();
 123    }
 124
 125    [UnconditionalSuppressMessage("TrimAnalysis", "IL2026", Justification = "Reflection fallback is unreachable in AOT b
 126    private IReadOnlyList<Type> ResolveGroupChatMembers(string groupName)
 127    {
 8128        if (AgentFrameworkGeneratedBootstrap.TryGetGroupChatGroups(out var provider))
 129        {
 8130            var groups = provider();
 8131            if (groups.TryGetValue(groupName, out var members))
 2132                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.
 6136            return ResolveGroupChatMembersViaReflection(groupName);
 137        }
 138
 0139        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    {
 6145        return AppDomain.CurrentDomain.GetAssemblies()
 6146            .SelectMany(a =>
 6147            {
 461148                try { return a.GetTypes(); }
 0149                catch { return []; }
 461150            })
 85305151            .Where(t => t.GetCustomAttributes<AgentGroupChatMemberAttribute>()
 85347152                         .Any(attr => string.Equals(attr.GroupName, groupName, StringComparison.Ordinal)))
 6153            .ToList()
 6154            .AsReadOnly();
 155    }
 156
 157    [UnconditionalSuppressMessage("TrimAnalysis", "IL2026", Justification = "Reflection fallback is unreachable in AOT b
 158    private static IReadOnlyList<Type> ResolveSequentialMembers(string pipelineName)
 159    {
 7160        if (AgentFrameworkGeneratedBootstrap.TryGetSequentialTopology(out var provider))
 161        {
 7162            var topology = provider();
 7163            if (topology.TryGetValue(pipelineName, out var members) && members.Count > 0)
 2164                return members;
 165
 5166            return ResolveSequentialMembersViaReflection(pipelineName);
 167        }
 168
 0169        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    {
 5175        var members = AppDomain.CurrentDomain.GetAssemblies()
 5176            .SelectMany(a =>
 5177            {
 410178                try { return a.GetTypes(); }
 0179                catch { return []; }
 410180            })
 73399181            .SelectMany(t => t.GetCustomAttributes<AgentSequenceMemberAttribute>()
 15182                .Where(attr => string.Equals(attr.PipelineName, pipelineName, StringComparison.Ordinal))
 73411183                .Select(attr => (Type: t, attr.Order)))
 12184            .OrderBy(x => x.Order)
 12185            .Select(x => x.Type)
 5186            .ToList()
 5187            .AsReadOnly();
 188
 5189        if (members.Count == 0)
 1190            throw new InvalidOperationException(
 1191                $"No agents found for sequential pipeline '{pipelineName}'. " +
 1192                $"Decorate agent classes with [AgentSequenceMember(\"{pipelineName}\", order)] and ensure their assembli
 193
 4194        return members;
 195    }
 196
 197    private Workflow BuildHandoff(
 198        AIAgent initialAgent,
 199        IReadOnlyList<(Type TargetType, string? HandoffReason)> targets)
 200    {
 8201        if (targets.Count == 0)
 202        {
 0203            throw new InvalidOperationException(
 0204                $"Cannot build handoff workflow for agent '{initialAgent.Name ?? initialAgent.Id}': no handoff targets f
 205        }
 206
 8207        var targetPairs = targets
 16208            .Select(t => (_agentFactory.CreateAgent(t.TargetType.Name), t.HandoffReason))
 8209            .ToArray();
 210
 8211        var builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent);
 212
 8213        var withoutReason = targetPairs
 16214            .Where(t => string.IsNullOrEmpty(t.HandoffReason))
 16215            .Select(t => t.Item1)
 8216            .ToArray();
 217
 8218        if (withoutReason.Length > 0)
 8219            builder.WithHandoffs(initialAgent, withoutReason);
 220
 32221        foreach (var (target, reason) in targetPairs.Where(t => !string.IsNullOrEmpty(t.HandoffReason)))
 0222            builder.WithHandoff(initialAgent, target, reason!);
 223
 8224        return builder.Build();
 225    }
 226
 227    private static IReadOnlyList<(string AgentName, IWorkflowTerminationCondition Condition)> BuildTerminationConditions
 228        IReadOnlyList<Type> memberTypes)
 229    {
 6230        var result = new List<(string, IWorkflowTerminationCondition)>();
 36231        foreach (var type in memberTypes)
 232        {
 26233            foreach (var attr in type.GetCustomAttributes<AgentTerminationConditionAttribute>())
 234            {
 1235                var condition = (IWorkflowTerminationCondition)Activator.CreateInstance(
 1236                    attr.ConditionType, attr.CtorArgs)!;
 1237                result.Add((type.Name, condition));
 238            }
 239        }
 6240        return result;
 241    }
 242
 243    private static Func<RoundRobinGroupChatManager, IEnumerable<Microsoft.Extensions.AI.ChatMessage>, CancellationToken,
 244        IReadOnlyList<(string AgentName, IWorkflowTerminationCondition Condition)> conditions)
 245    {
 0246        return (manager, history, ct) =>
 0247        {
 0248            var historyList = history.ToList();
 0249            if (historyList.Count == 0)
 0250                return ValueTask.FromResult(false);
 0251
 0252            var lastMessage = historyList[^1];
 0253            var agentId = lastMessage.AuthorName ?? string.Empty;
 0254            var responseText = lastMessage.Text ?? string.Empty;
 0255            var ctx = new TerminationContext
 0256            {
 0257                AgentId = agentId,
 0258                ResponseText = responseText,
 0259                TurnCount = historyList.Count,
 0260                ConversationHistory = historyList,
 0261            };
 0262
 0263            foreach (var (_, condition) in conditions)
 0264            {
 0265                if (condition.ShouldTerminate(ctx))
 0266                    return ValueTask.FromResult(true);
 0267            }
 0268
 0269            return ValueTask.FromResult(false);
 0270        };
 271    }
 272}