< 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
81%
Covered lines: 307
Uncovered lines: 72
Coverable lines: 379
Total lines: 741
Line coverage: 81%
Branch coverage
69%
Covered branches: 121
Total branches: 173
Branch coverage: 69.9%
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.Collections.Concurrent;
 2using System.Diagnostics.CodeAnalysis;
 3using System.Reflection;
 4using Microsoft.Agents.AI;
 5using Microsoft.Agents.AI.Workflows;
 6using Microsoft.Extensions.AI;
 7
 8namespace NexusLabs.Needlr.AgentFramework;
 9
 10/// <summary>
 11/// Default implementation of <see cref="IWorkflowFactory"/> that assembles MAF workflows from
 12/// topology declared via <see cref="AgentHandoffsToAttribute"/> and
 13/// <see cref="AgentGroupChatMemberAttribute"/>.
 14/// </summary>
 15/// <remarks>
 16/// When the source generator bootstrap is registered (i.e., the generator package is included
 17/// and <c>UsingAgentFramework()</c> detected a <c>[ModuleInitializer]</c>-emitted registration),
 18/// topology data is read from the compile-time registry for zero-allocation discovery.
 19/// When no bootstrap data is available, topology is discovered via reflection at workflow
 20/// creation time; that path is annotated <see cref="RequiresUnreferencedCodeAttribute"/>.
 21/// </remarks>
 22internal sealed class WorkflowFactory : IWorkflowFactory
 23{
 24    private readonly IAgentFactory _agentFactory;
 25
 8626    public WorkflowFactory(IAgentFactory agentFactory)
 27    {
 8628        ArgumentNullException.ThrowIfNull(agentFactory);
 8629        _agentFactory = agentFactory;
 8630    }
 31
 32    /// <inheritdoc/>
 33    public Workflow CreateHandoffWorkflow<TInitialAgent>() where TInitialAgent : class
 34    {
 935        var targets = ResolveHandoffTargets(typeof(TInitialAgent));
 836        var initialAgent = _agentFactory.CreateAgent<TInitialAgent>();
 837        return BuildHandoff(initialAgent, targets);
 38    }
 39
 40    /// <inheritdoc/>
 41    public Workflow CreateGroupChatWorkflow(string groupName, int maxIterations = 10)
 1042        => CreateGroupChatWorkflowCore(groupName, maxIterations, configureAgent: null);
 43
 44    /// <inheritdoc/>
 45    public Workflow CreateGroupChatWorkflow(string groupName, int maxIterations, Action<Type, AgentFactoryOptions> confi
 46    {
 647        ArgumentNullException.ThrowIfNull(configureAgent);
 648        return CreateGroupChatWorkflowCore(groupName, maxIterations, configureAgent);
 49    }
 50
 51    private Workflow CreateGroupChatWorkflowCore(string groupName, int maxIterations, Action<Type, AgentFactoryOptions>?
 52    {
 1653        ArgumentException.ThrowIfNullOrWhiteSpace(groupName);
 54
 1655        var memberTypes = ResolveGroupChatMembers(groupName)
 2856            .OrderBy(t => t.GetCustomAttributes<AgentGroupChatMemberAttribute>()
 2857                .Where(a => string.Equals(a.GroupName, groupName, StringComparison.Ordinal))
 2858                .Select(a => a.Order)
 2859                .FirstOrDefault())
 2860            .ThenBy(t => t.Name, StringComparer.Ordinal)
 1661            .ToList();
 62
 1663        if (memberTypes.Count < 2)
 64        {
 265            throw new InvalidOperationException(
 266                $"CreateGroupChatWorkflow(\"{groupName}\") failed: {memberTypes.Count} agent(s) are " +
 267                $"registered as members of group \"{groupName}\". At least two are required. " +
 268                $"Add [AgentGroupChatMember(\"{groupName}\")] to the agent classes and ensure their " +
 269                $"assemblies are scanned.");
 70        }
 71
 1472        var agents = memberTypes.Select(t =>
 1473        {
 2874            if (configureAgent is not null)
 2475                return _agentFactory.CreateAgent(t.FullName ?? t.Name, opts => configureAgent(t, opts));
 1676            return _agentFactory.CreateAgent(t.FullName ?? t.Name);
 1477        }).ToList();
 78
 1479        var conditions = BuildTerminationConditions(memberTypes);
 80
 1481        Func<IReadOnlyList<AIAgent>, RoundRobinGroupChatManager> managerFactory = conditions.Count > 0
 882            ? a => new RoundRobinGroupChatManager(a, ShouldTerminateAsync(conditions)) { MaximumIterationCount = maxIter
 1483            : a => new RoundRobinGroupChatManager(a) { MaximumIterationCount = maxIterations };
 84
 1485        return AgentWorkflowBuilder
 1486            .CreateGroupChatBuilderWith(managerFactory)
 1487            .AddParticipants(agents)
 1488            .Build();
 89    }
 90
 91    /// <inheritdoc/>
 92    public Workflow CreateSequentialWorkflow(params AIAgent[] agents)
 93    {
 094        ArgumentNullException.ThrowIfNull(agents);
 95
 096        if (agents.Length == 0)
 097            throw new ArgumentException("At least one agent is required.", nameof(agents));
 98
 099        return AgentWorkflowBuilder.BuildSequential(agents);
 100    }
 101
 102    /// <inheritdoc/>
 103    public Workflow CreateSequentialWorkflow(string pipelineName)
 104    {
 8105        ArgumentException.ThrowIfNullOrWhiteSpace(pipelineName);
 106
 8107        var memberTypes = ResolveSequentialMembers(pipelineName);
 27108        var agents = memberTypes.Select(t => _agentFactory.CreateAgent(t.FullName ?? t.Name)).ToArray();
 7109        return AgentWorkflowBuilder.BuildSequential(agents);
 110    }
 111
 112    [UnconditionalSuppressMessage("TrimAnalysis", "IL2026", Justification = "Reflection fallback is unreachable in AOT b
 113    private IReadOnlyList<(Type TargetType, string? HandoffReason)> ResolveHandoffTargets(Type initialAgentType)
 114    {
 9115        if (AgentFrameworkGeneratedBootstrap.TryGetHandoffTopology(out var provider))
 116        {
 9117            var topology = provider();
 9118            if (topology.TryGetValue(initialAgentType, out var targets))
 2119                return targets;
 120
 121            // Type is not in the bootstrap topology — it may be from an assembly that didn't run the
 122            // generator. Fall back to reflection so multi-assembly and test scenarios work correctly.
 7123            return ResolveHandoffTargetsViaReflection(initialAgentType);
 124        }
 125
 0126        return ResolveHandoffTargetsViaReflection(initialAgentType);
 127    }
 128
 129    [RequiresUnreferencedCode("Reflection-based topology discovery may not work after trimming. Use the source generator
 130    private static IReadOnlyList<(Type TargetType, string? HandoffReason)> ResolveHandoffTargetsViaReflection(Type initi
 131    {
 7132        var attrs = initialAgentType.GetCustomAttributes<AgentHandoffsToAttribute>().ToList();
 133
 7134        if (attrs.Count == 0)
 135        {
 1136            throw new InvalidOperationException(
 1137                $"CreateHandoffWorkflow<{initialAgentType.Name}>() failed: {initialAgentType.Name} has no " +
 1138                $"[AgentHandoffsTo] attributes. Declare at least one " +
 1139                $"[AgentHandoffsTo(typeof(TargetAgent))] on {initialAgentType.Name} to specify its handoff targets.");
 140        }
 141
 6142        return attrs
 12143            .Select(a => (a.TargetAgentType, a.HandoffReason))
 6144            .ToList()
 6145            .AsReadOnly();
 146    }
 147
 148    [UnconditionalSuppressMessage("TrimAnalysis", "IL2026", Justification = "Reflection fallback is unreachable in AOT b
 149    private IReadOnlyList<Type> ResolveGroupChatMembers(string groupName)
 150    {
 16151        if (AgentFrameworkGeneratedBootstrap.TryGetGroupChatGroups(out var provider))
 152        {
 16153            var groups = provider();
 16154            if (groups.TryGetValue(groupName, out var members))
 2155                return members;
 156
 157            // Group not in the bootstrap — may be from an assembly that didn't run the generator.
 158            // Fall back to reflection so multi-assembly and test scenarios work correctly.
 14159            return ResolveGroupChatMembersViaReflection(groupName);
 160        }
 161
 0162        return ResolveGroupChatMembersViaReflection(groupName);
 163    }
 164
 165    [RequiresUnreferencedCode("Reflection-based group chat discovery may not work after trimming. Use the source generat
 166    private static IReadOnlyList<Type> ResolveGroupChatMembersViaReflection(string groupName)
 167    {
 14168        return AppDomain.CurrentDomain.GetAssemblies()
 14169            .SelectMany(a =>
 14170            {
 1288171                try { return a.GetTypes(); }
 0172                catch { return []; }
 1288173            })
 241561174            .Where(t => t.GetCustomAttributes<AgentGroupChatMemberAttribute>()
 241715175                         .Any(attr => string.Equals(attr.GroupName, groupName, StringComparison.Ordinal)))
 14176            .ToList()
 14177            .AsReadOnly();
 178    }
 179
 180    [UnconditionalSuppressMessage("TrimAnalysis", "IL2026", Justification = "Reflection fallback is unreachable in AOT b
 181    private static IReadOnlyList<Type> ResolveSequentialMembers(string pipelineName)
 182    {
 8183        if (AgentFrameworkGeneratedBootstrap.TryGetSequentialTopology(out var provider))
 184        {
 8185            var topology = provider();
 8186            if (topology.TryGetValue(pipelineName, out var members) && members.Count > 0)
 2187                return members;
 188
 6189            return ResolveSequentialMembersViaReflection(pipelineName);
 190        }
 191
 0192        return ResolveSequentialMembersViaReflection(pipelineName);
 193    }
 194
 195    [RequiresUnreferencedCode("Reflection-based sequential pipeline discovery may not work after trimming. Use the sourc
 196    private static IReadOnlyList<Type> ResolveSequentialMembersViaReflection(string pipelineName)
 197    {
 6198        var members = AppDomain.CurrentDomain.GetAssemblies()
 6199            .SelectMany(a =>
 6200            {
 550201                try { return a.GetTypes(); }
 0202                catch { return []; }
 550203            })
 103413204            .SelectMany(t => t.GetCustomAttributes<AgentSequenceMemberAttribute>()
 30205                .Where(attr => string.Equals(attr.PipelineName, pipelineName, StringComparison.Ordinal))
 103427206                .Select(attr => (Type: t, attr.Order)))
 14207            .OrderBy(x => x.Order)
 14208            .Select(x => x.Type)
 6209            .ToList()
 6210            .AsReadOnly();
 211
 6212        if (members.Count == 0)
 1213            throw new InvalidOperationException(
 1214                $"No agents found for sequential pipeline '{pipelineName}'. " +
 1215                $"Decorate agent classes with [AgentSequenceMember(\"{pipelineName}\", order)] and ensure their assembli
 216
 5217        return members;
 218    }
 219
 220    private Workflow BuildHandoff(
 221        AIAgent initialAgent,
 222        IReadOnlyList<(Type TargetType, string? HandoffReason)> targets)
 223    {
 8224        if (targets.Count == 0)
 225        {
 0226            throw new InvalidOperationException(
 0227                $"Cannot build handoff workflow for agent '{initialAgent.Name ?? initialAgent.Id}': no handoff targets f
 228        }
 229
 8230        var targetPairs = targets
 16231            .Select(t => (_agentFactory.CreateAgent(t.TargetType.FullName ?? t.TargetType.Name), t.HandoffReason))
 8232            .ToArray();
 233
 8234        var builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent);
 235
 8236        var withoutReason = targetPairs
 16237            .Where(t => string.IsNullOrEmpty(t.HandoffReason))
 16238            .Select(t => t.Item1)
 8239            .ToArray();
 240
 8241        if (withoutReason.Length > 0)
 8242            builder.WithHandoffs(initialAgent, withoutReason);
 243
 32244        foreach (var (target, reason) in targetPairs.Where(t => !string.IsNullOrEmpty(t.HandoffReason)))
 0245            builder.WithHandoff(initialAgent, target, reason!);
 246
 8247        return builder.Build();
 248    }
 249
 250    private static IReadOnlyList<(string AgentName, IWorkflowTerminationCondition Condition)> BuildTerminationConditions
 251        IReadOnlyList<Type> memberTypes)
 252    {
 14253        var result = new List<(string, IWorkflowTerminationCondition)>();
 84254        foreach (var type in memberTypes)
 255        {
 74256            foreach (var attr in type.GetCustomAttributes<AgentTerminationConditionAttribute>())
 257            {
 9258                var condition = (IWorkflowTerminationCondition)Activator.CreateInstance(
 9259                    attr.ConditionType, attr.CtorArgs)!;
 9260                result.Add((type.Name, condition));
 261            }
 262        }
 14263        return result;
 264    }
 265
 266    private static Func<RoundRobinGroupChatManager, IEnumerable<Microsoft.Extensions.AI.ChatMessage>, CancellationToken,
 267        IReadOnlyList<(string AgentName, IWorkflowTerminationCondition Condition)> conditions)
 268    {
 8269        return (manager, history, ct) =>
 8270        {
 24271            var historyList = history.ToList();
 24272            if (historyList.Count == 0)
 0273                return ValueTask.FromResult(false);
 8274
 24275            var lastMessage = historyList[^1];
 24276            var agentId = lastMessage.AuthorName ?? string.Empty;
 8277
 24278            var toolCallNames = lastMessage.Contents
 24279                .OfType<FunctionCallContent>()
 0280                .Select(fc => fc.Name)
 0281                .Where(n => !string.IsNullOrEmpty(n))
 24282                .ToList();
 8283
 24284            var ctx = new TerminationContext
 24285            {
 24286                AgentId = agentId,
 24287                LastMessage = lastMessage,
 24288                TurnCount = historyList.Count,
 24289                ConversationHistory = historyList,
 24290                ToolCallNames = toolCallNames,
 24291            };
 8292
 94293            foreach (var (_, condition) in conditions)
 8294            {
 24295                if (condition.ShouldTerminate(ctx))
 2296                    return ValueTask.FromResult(true);
 8297            }
 8298
 22299            return ValueTask.FromResult(false);
 10300        };
 301    }
 302
 303    /// <inheritdoc />
 304    [RequiresUnreferencedCode("Graph workflow discovery uses reflection when source-generated bootstrap data is unavaila
 305    public Workflow CreateGraphWorkflow(string graphName)
 306    {
 20307        ArgumentException.ThrowIfNullOrWhiteSpace(graphName);
 308
 18309        var entryType = FindGraphEntryType(graphName);
 17310        var entryAttr = entryType.GetCustomAttributes<AgentGraphEntryAttribute>()
 34311            .First(a => string.Equals(a.GraphName, graphName, StringComparison.Ordinal));
 312
 17313        var edges = DiscoverGraphEdges(graphName);
 17314        if (edges.Count == 0)
 315        {
 1316            throw new InvalidOperationException(
 1317                $"Cannot build graph workflow '{graphName}': no edges found.");
 318        }
 319
 16320        var allAgentTypes = new HashSet<Type> { entryType };
 134321        foreach (var edge in edges)
 322        {
 51323            allAgentTypes.Add(edge.SourceType);
 51324            allAgentTypes.Add(edge.TargetType);
 325        }
 326
 327        // Create agents and bind executors ONCE per agent to avoid duplicate bindings.
 16328        var agents = new Dictionary<Type, AIAgent>();
 16329        var executorBindings = new Dictionary<Type, ExecutorBinding>();
 146330        foreach (var type in allAgentTypes)
 331        {
 57332            var agent = _agentFactory.CreateAgent(type.FullName ?? type.Name);
 57333            agents[type] = agent;
 57334            executorBindings[type] = agent.BindAsExecutor();
 335        }
 336
 337        // Discover [AgentGraphNode] attributes for JoinMode metadata.
 338        // WaitAll (default) maps to MAF's barrier-style edges (default AddEdge behavior).
 339        // WaitAny is supported via RunGraphAsync which uses Needlr's own executor.
 340        // CreateGraphWorkflow only supports WaitAll since it returns a MAF Workflow.
 16341        var nodeJoinModes = DiscoverNodeJoinModes(graphName, allAgentTypes);
 53342        foreach (var (type, joinMode) in nodeJoinModes)
 343        {
 11344            if (joinMode == GraphJoinMode.WaitAny)
 345            {
 1346                throw new NotSupportedException(
 1347                    $"GraphJoinMode.WaitAny on '{type.FullName ?? type.Name}' in graph '{graphName}' is not compatible "
 1348                    $"with CreateGraphWorkflow (which returns a MAF Workflow using BSP execution). " +
 1349                    $"Use RunGraphAsync(\"{graphName}\", input) instead — it handles WaitAny via " +
 1350                    $"Needlr's own graph executor.");
 351            }
 352        }
 353
 15354        var builder = new WorkflowBuilder(executorBindings[entryType]);
 355
 356        // Discover reducer bindings before wiring edges so fan-in edges can be
 357        // routed through the reducer FunctionExecutor node.
 15358        var reducerBinding = DiscoverReducerBinding(graphName);
 359
 360        // Identify fan-in targets (agent types with two or more incoming edges)
 361        // so their inbound edges can be redirected through the reducer.
 15362        var fanInSources = new Dictionary<Type, List<Type>>();
 128363        foreach (var edge in edges)
 364        {
 49365            if (!fanInSources.TryGetValue(edge.TargetType, out var sources))
 366            {
 39367                sources = [];
 39368                fanInSources[edge.TargetType] = sources;
 369            }
 370
 49371            sources.Add(edge.SourceType);
 372        }
 373
 15374        var fanInTargets = reducerBinding is not null
 15375            ? fanInSources
 18376                .Where(kv => kv.Value.Count >= 2)
 6377                .Select(kv => kv.Key)
 15378                .ToHashSet()
 15379            : [];
 380
 381        // Compute effective routing mode per source node: per-node override wins,
 382        // then graph-wide default from the entry attribute.
 15383        var graphRoutingMode = entryAttr.RoutingMode;
 132384        var edgesBySource = edges.GroupBy(e => e.SourceType).ToDictionary(g => g.Key, g => g.ToList());
 15385        var effectiveRoutingModes = new Dictionary<Type, GraphRoutingMode>();
 98386        foreach (var (sourceType, sourceEdges) in edgesBySource)
 387        {
 34388            var nodeOverride = sourceEdges
 49389                .Select(e => e.NodeRoutingModeOverride)
 83390                .FirstOrDefault(m => m is not null);
 34391            effectiveRoutingModes[sourceType] = nodeOverride ?? graphRoutingMode;
 392        }
 393
 394        // Validate: LlmChoice is not supported in the BSP path — it requires
 395        // async LLM calls that CreateGraphWorkflow (synchronous build) cannot provide.
 97396        foreach (var (sourceType, routingMode) in effectiveRoutingModes)
 397        {
 34398            if (routingMode == GraphRoutingMode.LlmChoice)
 399            {
 1400                throw new NotSupportedException(
 1401                    $"GraphRoutingMode.LlmChoice on '{sourceType.FullName ?? sourceType.Name}' in graph '{graphName}' is
 1402                    $"with CreateGraphWorkflow (which returns a MAF Workflow using BSP execution). " +
 1403                    $"Use RunGraphAsync(\"{graphName}\", input) instead — it handles LlmChoice via " +
 1404                    $"Needlr's own graph executor with an IChatClient.");
 405            }
 406        }
 407
 14408        if (reducerBinding is not null && fanInTargets.Count > 0)
 409        {
 6410            builder.BindExecutor(reducerBinding);
 6411            var wiredFanInTargets = new HashSet<Type>();
 412
 60413            foreach (var edge in edges)
 414            {
 24415                if (fanInTargets.Contains(edge.TargetType))
 416                {
 417                    // Redirect fan-in edges through the reducer function node:
 418                    // source → reducer (instead of source → fan-in agent)
 12419                    AddRoutedEdge(builder, executorBindings[edge.SourceType], reducerBinding,
 12420                        edge, effectiveRoutingModes, edgesBySource);
 421
 422                    // reducer → original fan-in agent (wired once per target)
 12423                    if (wiredFanInTargets.Add(edge.TargetType))
 424                    {
 6425                        builder.AddEdge(reducerBinding, executorBindings[edge.TargetType]);
 426                    }
 427                }
 428                else
 429                {
 12430                    AddRoutedEdge(builder, executorBindings[edge.SourceType], executorBindings[edge.TargetType],
 12431                        edge, effectiveRoutingModes, edgesBySource);
 432                }
 433            }
 434        }
 435        else
 436        {
 62437            foreach (var edge in edges)
 438            {
 23439                AddRoutedEdge(builder, executorBindings[edge.SourceType], executorBindings[edge.TargetType],
 23440                    edge, effectiveRoutingModes, edgesBySource);
 441            }
 442        }
 443
 14444        return builder.Build();
 445    }
 446
 447    private static Dictionary<Type, GraphJoinMode> DiscoverNodeJoinModes(
 448        string graphName,
 449        IEnumerable<Type> agentTypes)
 450    {
 16451        var result = new Dictionary<Type, GraphJoinMode>();
 146452        foreach (var type in agentTypes)
 453        {
 57454            var nodeAttr = type.GetCustomAttributes<AgentGraphNodeAttribute>()
 68455                .FirstOrDefault(a => string.Equals(a.GraphName, graphName, StringComparison.Ordinal));
 57456            if (nodeAttr is not null)
 457            {
 11458                result[type] = nodeAttr.JoinMode;
 459            }
 460        }
 461
 16462        return result;
 463    }
 464
 465    /// <summary>
 466    /// Discovers a single <see cref="AgentGraphReducerAttribute"/> for the graph and
 467    /// creates a <see cref="FunctionExecutor{TInput, TOutput}"/> wrapped in an
 468    /// <see cref="ExecutorBinding"/> so it can participate as a node in the
 469    /// <see cref="WorkflowBuilder"/> DAG.
 470    /// </summary>
 471    /// <returns>
 472    /// An <see cref="ExecutorBinding"/> for the reducer, or <c>null</c> if no reducer
 473    /// is declared for this graph.
 474    /// </returns>
 475    [RequiresUnreferencedCode("Reducer discovery uses reflection to find [AgentGraphReducer] and invoke static methods."
 476    private static ExecutorBinding? DiscoverReducerBinding(string graphName)
 477    {
 1696478        foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
 479        {
 480            Type[] types;
 1672481            try { types = assembly.GetTypes(); }
 0482            catch { continue; }
 483
 347524484            foreach (var type in types)
 485            {
 345972486                foreach (var attr in type.GetCustomAttributes<AgentGraphReducerAttribute>())
 487                {
 60488                    if (!string.Equals(attr.GraphName, graphName, StringComparison.Ordinal))
 489                        continue;
 490
 6491                    if (string.IsNullOrWhiteSpace(attr.ReducerMethod))
 492                        continue;
 493
 6494                    var method = type.GetMethod(
 6495                        attr.ReducerMethod,
 6496                        BindingFlags.Public | BindingFlags.Static,
 6497                        null,
 6498                        [typeof(IReadOnlyList<string>)],
 6499                        null);
 500
 6501                    if (method is null || method.ReturnType != typeof(string))
 502                    {
 0503                        throw new InvalidOperationException(
 0504                            $"[AgentGraphReducer] on {type.FullName ?? type.Name} references method '{attr.ReducerMethod
 0505                            $"but no matching 'public static string {attr.ReducerMethod}(IReadOnlyList<string>)' was fou
 506                    }
 507
 6508                    return CreateReducerExecutorBinding(type, method);
 509                }
 510            }
 511        }
 512
 9513        return null;
 6514    }
 515
 516    private static ExecutorBinding CreateReducerExecutorBinding(Type reducerType, MethodInfo reducerMethod)
 517    {
 6518        var reducerId = $"reducer:{reducerType.FullName ?? reducerType.Name}";
 519
 520        // The FunctionExecutor receives a string input per invocation. In the
 521        // BSP model each superstep delivers one message. The reducer is invoked
 522        // with a single-element list per call — the downstream agent sees the
 523        // reduced output from each branch independently. No shared mutable
 524        // state is needed because each invocation is self-contained.
 6525        var executor = new FunctionExecutor<string, string>(
 6526            reducerId,
 6527            (input, _, _) =>
 6528            {
 0529                var inputs = new List<string> { input };
 0530                var result = (string)reducerMethod.Invoke(
 0531                    null,
 0532                    [inputs.AsReadOnly()])!;
 0533                return new ValueTask<string>(result);
 6534            },
 6535            options: null,
 6536            sentMessageTypes: null,
 6537            outputTypes: null,
 6538            declareCrossRunShareable: false);
 539
 6540        return new ExecutorInstanceBinding(executor);
 541    }
 542
 543    private Type FindGraphEntryType(string graphName)
 544    {
 271545        foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
 546        {
 547            Type[] types;
 252548            try { types = assembly.GetTypes(); }
 0549            catch { continue; }
 550
 135565551            foreach (var type in types)
 552            {
 67665553                var entry = type.GetCustomAttributes<AgentGraphEntryAttribute>()
 68113554                    .FirstOrDefault(a => string.Equals(a.GraphName, graphName, StringComparison.Ordinal));
 67665555                if (entry is not null)
 556                {
 17557                    return type;
 558                }
 559            }
 560        }
 561
 1562        throw new InvalidOperationException(
 1563            $"Cannot build graph workflow '{graphName}': no entry point found. " +
 1564            $"Ensure exactly one agent class has [AgentGraphEntry(\"{graphName}\")].");
 565    }
 566
 567    private static List<GraphEdgeInfo> DiscoverGraphEdges(string graphName)
 568    {
 17569        var edges = new List<GraphEdgeInfo>();
 570
 3110571        foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
 572        {
 573            Type[] types;
 3076574            try { types = assembly.GetTypes(); }
 0575            catch { continue; }
 576
 584294577            foreach (var type in types)
 578            {
 584278579                foreach (var attr in type.GetCustomAttributes<AgentGraphEdgeAttribute>())
 580                {
 1530581                    if (string.Equals(attr.GraphName, graphName, StringComparison.Ordinal))
 582                    {
 51583                        edges.Add(new GraphEdgeInfo(
 51584                            type,
 51585                            attr.TargetAgentType,
 51586                            attr.Condition,
 51587                            attr.IsRequired,
 51588                            attr.HasNodeRoutingMode ? attr.NodeRoutingMode : null));
 589                    }
 590                }
 591            }
 592        }
 593
 17594        return edges;
 595    }
 596
 597    /// <summary>
 598    /// Wires a single edge into the <see cref="WorkflowBuilder"/>, applying
 599    /// condition functions according to the effective routing mode for the
 600    /// source node.
 601    /// </summary>
 602    /// <remarks>
 603    /// <para>
 604    /// <see cref="GraphEdgeInfo.IsRequired"/> is intentionally NOT wired in the BSP path.
 605    /// MAF's <see cref="WorkflowBuilder.AddEdge(ExecutorBinding, ExecutorBinding)"/> API
 606    /// has no concept of optional vs. required edges — all edges are implicitly required.
 607    /// The <c>IsRequired</c> semantic is a Needlr-native-executor-only feature handled by
 608    /// <c>RunGraphAsync</c>, which uses Needlr's own graph executor.
 609    /// </para>
 610    /// </remarks>
 611    private static void AddRoutedEdge(
 612        WorkflowBuilder builder,
 613        ExecutorBinding source,
 614        ExecutorBinding target,
 615        GraphEdgeInfo edge,
 616        Dictionary<Type, GraphRoutingMode> effectiveRoutingModes,
 617        Dictionary<Type, List<GraphEdgeInfo>> edgesBySource)
 618    {
 47619        var routingMode = effectiveRoutingModes.GetValueOrDefault(edge.SourceType, GraphRoutingMode.Deterministic);
 620
 47621        if (edge.Condition is null)
 622        {
 42623            builder.AddEdge(source, target);
 42624            return;
 625        }
 626
 627        switch (routingMode)
 628        {
 629            case GraphRoutingMode.Deterministic:
 630            case GraphRoutingMode.AllMatching:
 0631                builder.AddEdge<object>(source, target,
 0632                    input => EvaluateEdgeCondition(edge.SourceType, edge.Condition, input));
 0633                break;
 634
 635            case GraphRoutingMode.FirstMatching:
 636            {
 1637                var sourceEdges = edgesBySource[edge.SourceType];
 1638                var edgeIndex = sourceEdges.IndexOf(edge);
 1639                var precedingConditionalEdges = sourceEdges
 1640                    .Take(edgeIndex)
 0641                    .Where(e => e.Condition is not null)
 1642                    .ToList();
 643
 1644                builder.AddEdge<object>(source, target, input =>
 1645                {
 1646                    // Only follow this edge if its condition passes AND
 1647                    // no earlier conditional edge's condition passed.
 0648                    if (!EvaluateEdgeCondition(edge.SourceType, edge.Condition, input))
 0649                        return false;
 1650
 0651                    foreach (var earlier in precedingConditionalEdges)
 1652                    {
 0653                        if (EvaluateEdgeCondition(edge.SourceType, earlier.Condition!, input))
 0654                            return false;
 1655                    }
 1656
 0657                    return true;
 1658                });
 1659                break;
 660            }
 661
 662            case GraphRoutingMode.ExclusiveChoice:
 663            {
 4664                var sourceEdges = edgesBySource[edge.SourceType];
 4665                builder.AddEdge<object>(source, target, input =>
 4666                {
 0667                    var matchCount = 0;
 0668                    var thisMatches = false;
 4669
 0670                    foreach (var e in sourceEdges)
 4671                    {
 0672                        if (e.Condition is null)
 4673                            continue;
 0674                        if (EvaluateEdgeCondition(edge.SourceType, e.Condition, input))
 4675                        {
 0676                            matchCount++;
 0677                            if (ReferenceEquals(e, edge))
 0678                                thisMatches = true;
 4679                        }
 4680                    }
 4681
 0682                    if (matchCount == 0)
 4683                    {
 0684                        throw new InvalidOperationException(
 0685                            $"ExclusiveChoice routing on '{edge.SourceType.Name}': no edge condition matched. " +
 0686                            $"Exactly one must match.");
 4687                    }
 4688
 0689                    if (matchCount > 1)
 4690                    {
 0691                        var names = string.Join(", ", sourceEdges
 0692                            .Where(e => e.Condition is not null && EvaluateEdgeCondition(edge.SourceType, e.Condition, i
 0693                            .Select(e => e.TargetType.Name));
 0694                        throw new InvalidOperationException(
 0695                            $"ExclusiveChoice routing on '{edge.SourceType.Name}': {matchCount} edges matched " +
 0696                            $"({names}). Exactly one must match.");
 4697                    }
 4698
 0699                    return thisMatches;
 4700                });
 4701                break;
 702            }
 703
 704            default:
 0705                builder.AddEdge(source, target);
 706                break;
 707        }
 0708    }
 709
 710    /// <summary>
 711    /// Evaluates a condition string by looking up a static method on the source
 712    /// agent type that accepts <c>object?</c> and returns <c>bool</c>.
 713    /// </summary>
 714    [RequiresUnreferencedCode("Condition evaluation uses reflection to invoke static predicate methods on agent types.")
 715    private static bool EvaluateEdgeCondition(Type sourceType, string conditionMethodName, object? upstreamOutput)
 716    {
 0717        var method = sourceType.GetMethod(
 0718            conditionMethodName,
 0719            BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic,
 0720            null,
 0721            [typeof(object)],
 0722            null);
 723
 0724        if (method is null)
 725        {
 0726            method = sourceType.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic)
 0727                .FirstOrDefault(m => m.Name == conditionMethodName && m.GetParameters().Length == 1);
 728        }
 729
 0730        if (method is null || method.ReturnType != typeof(bool))
 731        {
 0732            throw new InvalidOperationException(
 0733                $"Condition '{conditionMethodName}' on '{sourceType.Name}' must be a static method " +
 0734                $"with signature 'static bool {conditionMethodName}(object? upstreamOutput)'.");
 735        }
 736
 0737        return (bool)method.Invoke(null, [upstreamOutput])!;
 738    }
 739
 611740    private sealed record GraphEdgeInfo(Type SourceType, Type TargetType, string? Condition, bool IsRequired, GraphRouti
 741}

Methods/Properties

.ctor(NexusLabs.Needlr.AgentFramework.IAgentFactory)
CreateHandoffWorkflow()
CreateGroupChatWorkflow(System.String,System.Int32)
CreateGroupChatWorkflow(System.String,System.Int32,System.Action`2<System.Type,NexusLabs.Needlr.AgentFramework.AgentFactoryOptions>)
CreateGroupChatWorkflowCore(System.String,System.Int32,System.Action`2<System.Type,NexusLabs.Needlr.AgentFramework.AgentFactoryOptions>)
CreateSequentialWorkflow(Microsoft.Agents.AI.AIAgent[])
CreateSequentialWorkflow(System.String)
ResolveHandoffTargets(System.Type)
ResolveHandoffTargetsViaReflection(System.Type)
ResolveGroupChatMembers(System.String)
ResolveGroupChatMembersViaReflection(System.String)
ResolveSequentialMembers(System.String)
ResolveSequentialMembersViaReflection(System.String)
BuildHandoff(Microsoft.Agents.AI.AIAgent,System.Collections.Generic.IReadOnlyList`1<System.ValueTuple`2<System.Type,System.String>>)
BuildTerminationConditions(System.Collections.Generic.IReadOnlyList`1<System.Type>)
ShouldTerminateAsync(System.Collections.Generic.IReadOnlyList`1<System.ValueTuple`2<System.String,NexusLabs.Needlr.AgentFramework.IWorkflowTerminationCondition>>)
CreateGraphWorkflow(System.String)
DiscoverNodeJoinModes(System.String,System.Collections.Generic.IEnumerable`1<System.Type>)
DiscoverReducerBinding(System.String)
CreateReducerExecutorBinding(System.Type,System.Reflection.MethodInfo)
FindGraphEntryType(System.String)
DiscoverGraphEdges(System.String)
AddRoutedEdge(Microsoft.Agents.AI.Workflows.WorkflowBuilder,Microsoft.Agents.AI.Workflows.ExecutorBinding,Microsoft.Agents.AI.Workflows.ExecutorBinding,NexusLabs.Needlr.AgentFramework.WorkflowFactory/GraphEdgeInfo,System.Collections.Generic.Dictionary`2<System.Type,NexusLabs.Needlr.AgentFramework.GraphRoutingMode>,System.Collections.Generic.Dictionary`2<System.Type,System.Collections.Generic.List`1<NexusLabs.Needlr.AgentFramework.WorkflowFactory/GraphEdgeInfo>>)
EvaluateEdgeCondition(System.Type,System.String,System.Object)
get_SourceType()