< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Workflows.StreamingRunWorkflowExtensions
Assembly: NexusLabs.Needlr.AgentFramework.Workflows
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Workflows/StreamingRunWorkflowExtensions.cs
Line coverage
72%
Covered lines: 68
Uncovered lines: 26
Coverable lines: 94
Total lines: 253
Line coverage: 72.3%
Branch coverage
83%
Covered branches: 40
Total branches: 48
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
RunAsync()100%1150%
RunAsync()100%210%
RunAsync()0%3233.33%
RunAsync()0%4260%
CollectAgentResponsesAsync(...)100%210%
CollectFromEventsAsync()100%1212100%
CollectWithTerminationAsync()100%2424100%
ShouldTerminate(...)100%44100%
FinalizeResponses(...)100%11100%

File(s)

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

#LineLine coverage
 1using Microsoft.Agents.AI.Workflows;
 2using Microsoft.Extensions.AI;
 3using NexusLabs.Needlr.AgentFramework;
 4
 5namespace NexusLabs.Needlr.AgentFramework.Workflows;
 6
 7/// <summary>
 8/// Extension methods on <see cref="StreamingRun"/> and <see cref="Workflow"/> for collecting agent responses.
 9/// </summary>
 10public static class StreamingRunWorkflowExtensions
 11{
 12    /// <summary>
 13    /// Creates a streaming execution of the workflow, sends the message, and collects all agent responses.
 14    /// </summary>
 15    /// <param name="workflow">The workflow to execute.</param>
 16    /// <param name="message">The user message to send to the workflow.</param>
 17    /// <param name="cancellationToken">Optional cancellation token.</param>
 18    /// <returns>
 19    /// A dictionary mapping each agent's executor ID to its complete response text.
 20    /// Agents that emitted no text produce no entry.
 21    /// </returns>
 22    public static async Task<IReadOnlyDictionary<string, string>> RunAsync(
 23        this Workflow workflow,
 24        string message,
 25        CancellationToken cancellationToken = default)
 26    {
 327        ArgumentNullException.ThrowIfNull(workflow);
 228        ArgumentException.ThrowIfNullOrEmpty(message);
 029        return await workflow.RunAsync(new ChatMessage(ChatRole.User, message), cancellationToken);
 030    }
 31
 32    /// <summary>
 33    /// Creates a streaming execution of the workflow, sends the message, collects all agent
 34    /// responses, and stops early when any termination condition is met.
 35    /// </summary>
 36    /// <param name="workflow">The workflow to execute.</param>
 37    /// <param name="message">The user message to send to the workflow.</param>
 38    /// <param name="terminationConditions">
 39    /// Conditions evaluated after each completed agent turn. The first condition that returns
 40    /// <see langword="true"/> causes the loop to stop and remaining responses to be discarded.
 41    /// Pass an empty collection (or <see langword="null"/>) to disable Layer 2 termination.
 42    /// </param>
 43    /// <param name="cancellationToken">Optional cancellation token.</param>
 44    /// <returns>
 45    /// A dictionary mapping each agent's executor ID to its complete response text up to the
 46    /// point of termination. Agents that emitted no text produce no entry.
 47    /// </returns>
 48    public static async Task<IReadOnlyDictionary<string, string>> RunAsync(
 49        this Workflow workflow,
 50        string message,
 51        IReadOnlyList<IWorkflowTerminationCondition>? terminationConditions,
 52        CancellationToken cancellationToken = default)
 53    {
 054        ArgumentNullException.ThrowIfNull(workflow);
 055        ArgumentException.ThrowIfNullOrEmpty(message);
 056        return await workflow.RunAsync(
 057            new ChatMessage(ChatRole.User, message),
 058            terminationConditions,
 059            cancellationToken);
 060    }
 61
 62    /// <summary>
 63    /// Creates a streaming execution of the workflow, sends the message, and collects all agent responses.
 64    /// </summary>
 65    /// <param name="workflow">The workflow to execute.</param>
 66    /// <param name="message">The chat message to send to the workflow.</param>
 67    /// <param name="cancellationToken">Optional cancellation token.</param>
 68    /// <returns>
 69    /// A dictionary mapping each agent's executor ID to its complete response text.
 70    /// Agents that emitted no text produce no entry.
 71    /// </returns>
 72    public static async Task<IReadOnlyDictionary<string, string>> RunAsync(
 73        this Workflow workflow,
 74        ChatMessage message,
 75        CancellationToken cancellationToken = default)
 76    {
 277        ArgumentNullException.ThrowIfNull(workflow);
 178        ArgumentNullException.ThrowIfNull(message);
 079        await using var run = await InProcessExecution.RunStreamingAsync(workflow, message, cancellationToken: cancellat
 080        await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
 081        return await run.CollectAgentResponsesAsync(cancellationToken);
 082    }
 83
 84    /// <summary>
 85    /// Creates a streaming execution of the workflow, sends the message, collects all agent
 86    /// responses, and stops early when any termination condition is met.
 87    /// </summary>
 88    /// <param name="workflow">The workflow to execute.</param>
 89    /// <param name="message">The chat message to send to the workflow.</param>
 90    /// <param name="terminationConditions">
 91    /// Conditions evaluated after each completed agent turn. The first condition that returns
 92    /// <see langword="true"/> causes the loop to stop and remaining responses to be discarded.
 93    /// Pass an empty collection (or <see langword="null"/>) to disable Layer 2 termination.
 94    /// </param>
 95    /// <param name="cancellationToken">Optional cancellation token.</param>
 96    /// <returns>
 97    /// A dictionary mapping each agent's executor ID to its complete response text up to the
 98    /// point of termination. Agents that emitted no text produce no entry.
 99    /// </returns>
 100    public static async Task<IReadOnlyDictionary<string, string>> RunAsync(
 101        this Workflow workflow,
 102        ChatMessage message,
 103        IReadOnlyList<IWorkflowTerminationCondition>? terminationConditions,
 104        CancellationToken cancellationToken = default)
 105    {
 0106        ArgumentNullException.ThrowIfNull(workflow);
 0107        ArgumentNullException.ThrowIfNull(message);
 0108        await using var run = await InProcessExecution.RunStreamingAsync(workflow, message, cancellationToken: cancellat
 0109        await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
 110
 0111        if (terminationConditions is null || terminationConditions.Count == 0)
 0112            return await run.CollectAgentResponsesAsync(cancellationToken);
 113
 0114        return await CollectWithTerminationAsync(
 0115            run.WatchStreamAsync(cancellationToken),
 0116            terminationConditions,
 0117            cancellationToken);
 0118    }
 119
 120    /// <summary>
 121    /// Collects all agent response text from a streaming run, grouped by executor ID.
 122    /// </summary>
 123    /// <returns>
 124    /// A dictionary mapping each agent's executor ID to its complete response text.
 125    /// Agents that emitted no text produce no entry.
 126    /// </returns>
 127    public static Task<IReadOnlyDictionary<string, string>> CollectAgentResponsesAsync(
 128        this StreamingRun run,
 129        CancellationToken cancellationToken = default)
 130    {
 0131        ArgumentNullException.ThrowIfNull(run);
 0132        return CollectFromEventsAsync(run.WatchStreamAsync(cancellationToken));
 133    }
 134
 135    internal static async Task<IReadOnlyDictionary<string, string>> CollectFromEventsAsync(
 136        IAsyncEnumerable<WorkflowEvent> events)
 137    {
 5138        var responses = new Dictionary<string, System.Text.StringBuilder>();
 139
 30140        await foreach (var evt in events)
 141        {
 10142            if (evt is AgentResponseUpdateEvent update
 10143                && update.ExecutorId is not null
 10144                && update.Data is not null)
 145            {
 10146                var text = update.Data.ToString();
 10147                if (string.IsNullOrEmpty(text))
 148                    continue;
 149
 9150                if (!responses.TryGetValue(update.ExecutorId, out var sb))
 6151                    responses[update.ExecutorId] = sb = new System.Text.StringBuilder();
 152
 9153                sb.Append(text);
 154            }
 155        }
 156
 5157        return responses.ToDictionary(
 6158            kv => kv.Key,
 11159            kv => kv.Value.ToString());
 5160    }
 161
 162    internal static async Task<IReadOnlyDictionary<string, string>> CollectWithTerminationAsync(
 163        IAsyncEnumerable<WorkflowEvent> events,
 164        IReadOnlyList<IWorkflowTerminationCondition> conditions,
 165        CancellationToken cancellationToken)
 166    {
 8167        var responses = new Dictionary<string, System.Text.StringBuilder>();
 8168        var history = new List<ChatMessage>();
 8169        var turnCount = 0;
 170
 8171        string? currentExecutorId = null;
 172
 48173        await foreach (var evt in events.WithCancellation(cancellationToken))
 174        {
 18175            if (evt is not AgentResponseUpdateEvent update
 18176                || update.ExecutorId is null
 18177                || update.Data is null)
 178            {
 179                continue;
 180            }
 181
 18182            var text = update.Data.ToString();
 18183            if (string.IsNullOrEmpty(text))
 184                continue;
 185
 186            // Detect executor change — previous agent's turn is complete
 18187            if (currentExecutorId is not null
 18188                && currentExecutorId != update.ExecutorId
 18189                && responses.TryGetValue(currentExecutorId, out var completedSb))
 190            {
 9191                var responseText = completedSb.ToString();
 9192                turnCount++;
 9193                history.Add(new ChatMessage(ChatRole.Assistant, responseText));
 194
 9195                var ctx = new TerminationContext
 9196                {
 9197                    AgentId = currentExecutorId,
 9198                    ResponseText = responseText,
 9199                    TurnCount = turnCount,
 9200                    ConversationHistory = history,
 9201                };
 202
 9203                if (ShouldTerminate(ctx, conditions))
 4204                    return FinalizeResponses(responses);
 205            }
 206
 14207            currentExecutorId = update.ExecutorId;
 208
 14209            if (!responses.TryGetValue(update.ExecutorId, out var sb))
 12210                responses[update.ExecutorId] = sb = new System.Text.StringBuilder();
 211
 14212            sb.Append(text);
 213        }
 214
 215        // Check the last agent's turn
 4216        if (currentExecutorId is not null
 4217            && responses.TryGetValue(currentExecutorId, out var lastSb))
 218        {
 3219            var responseText = lastSb.ToString();
 3220            turnCount++;
 3221            history.Add(new ChatMessage(ChatRole.Assistant, responseText));
 222
 3223            var ctx = new TerminationContext
 3224            {
 3225                AgentId = currentExecutorId,
 3226                ResponseText = responseText,
 3227                TurnCount = turnCount,
 3228                ConversationHistory = history,
 3229            };
 230
 3231            ShouldTerminate(ctx, conditions); // evaluate but don't stop — stream already ended
 232        }
 233
 4234        return FinalizeResponses(responses);
 8235    }
 236
 237    private static bool ShouldTerminate(
 238        TerminationContext ctx,
 239        IReadOnlyList<IWorkflowTerminationCondition> conditions)
 240    {
 40241        foreach (var condition in conditions)
 242        {
 10243            if (condition.ShouldTerminate(ctx))
 4244                return true;
 245        }
 8246        return false;
 4247    }
 248
 249    private static IReadOnlyDictionary<string, string> FinalizeResponses(
 250        Dictionary<string, System.Text.StringBuilder> responses)
 32251        => responses.ToDictionary(kv => kv.Key, kv => kv.Value.ToString());
 252}
 253