< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Workflows.Sequential.IterativeLoopStageExecutor
Assembly: NexusLabs.Needlr.AgentFramework.Workflows
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Workflows/Sequential/IterativeLoopStageExecutor.cs
Line coverage
96%
Covered lines: 62
Uncovered lines: 2
Coverable lines: 64
Total lines: 214
Line coverage: 96.8%
Branch coverage
96%
Covered branches: 31
Total branches: 32
Branch coverage: 96.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ExecuteAsync()100%1818100%
MapTermination(...)92.85%141490.9%

File(s)

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

#LineLine coverage
 1using NexusLabs.Needlr.AgentFramework.Diagnostics;
 2using NexusLabs.Needlr.AgentFramework.Iterative;
 3
 4namespace NexusLabs.Needlr.AgentFramework.Workflows.Sequential;
 5
 6/// <summary>
 7/// Executes a pipeline stage by running an <see cref="IIterativeAgentLoop"/> with
 8/// dynamically constructed options and context.
 9/// </summary>
 10/// <remarks>
 11/// <para>
 12/// This executor bridges the workspace-driven iterative loop pattern into the
 13/// <see cref="IStageExecutor"/> contract used by <see cref="SequentialPipelineRunner"/>.
 14/// Unlike <see cref="AgentStageExecutor"/> (which wraps a single-pass
 15/// <c>AIAgent.RunAsync</c> call), this executor runs a multi-iteration loop where each
 16/// iteration builds a fresh prompt from workspace state, maintaining O(n) token cost.
 17/// </para>
 18/// <para>
 19/// All termination paths use result-based signaling — the executor never throws
 20/// exceptions for loop-level failures. This means exception-driven decorators like
 21/// <see cref="ContinueOnFailureExecutor"/> and <see cref="FallbackExecutor"/> do not
 22/// intercept loop termination results. For advisory behavior, set
 23/// <c>failureDisposition</c> to <see cref="FailureDisposition.ContinueAdvisory"/>.
 24/// For timeout enforcement, <see cref="TimeoutExecutor"/> still works because the loop
 25/// observes the linked <see cref="CancellationToken"/> and terminates cooperatively.
 26/// </para>
 27/// <para>
 28/// On every successful loop completion, the executor maps
 29/// <see cref="IterativeLoopResult.Termination"/> (a <see cref="TerminationReason"/>
 30/// enum) to a typed <see cref="StageTermination"/> case and surfaces it via
 31/// <see cref="StageExecutionResult.Termination"/>. The
 32/// <c>onLoopCompleted</c> callback can return a <see cref="StageTermination"/> to
 33/// override the framework-mapped default (e.g. to attach app-specific narrative as
 34/// a <see cref="StageTermination.Custom"/> case); returning <see langword="null"/>
 35/// uses the framework default.
 36/// </para>
 37/// </remarks>
 38/// <example>
 39/// <code>
 40/// // Basic usage
 41/// var executor = new IterativeLoopStageExecutor(
 42///     iterativeLoop,
 43///     ctx =&gt; new IterativeLoopOptions
 44///     {
 45///         Instructions = "Write an article.",
 46///         Tools = tools,
 47///         PromptFactory = iterCtx =&gt; BuildPrompt(iterCtx.Workspace),
 48///         MaxIterations = 15,
 49///         LoopName = ctx.StageName,
 50///     });
 51///
 52/// // With onLoopCompleted to override the framework-mapped termination
 53/// var executor = new IterativeLoopStageExecutor(
 54///     iterativeLoop,
 55///     ctx =&gt; buildOptions(ctx),
 56///     onLoopCompleted: (loopResult, ctx) =&gt;
 57///     {
 58///         accessor.LastDiagnostics = loopResult.Diagnostics;
 59///         // Return a Custom termination to attach app narrative + metadata.
 60///         return new StageTermination.Custom(
 61///             Reason: "Reconciled",
 62///             Properties: new Dictionary&lt;string, object?&gt; { ["FindingCount"] = 7 });
 63///     });
 64///
 65/// // With shouldTreatAsSuccess for acceptable non-success terminations
 66/// var executor = new IterativeLoopStageExecutor(
 67///     iterativeLoop,
 68///     ctx =&gt; buildOptions(ctx),
 69///     shouldTreatAsSuccess: r =&gt;
 70///         r.Termination is TerminationReason.MaxIterationsReached
 71///                       or TerminationReason.MaxToolCallsReached);
 72///
 73/// // Composing with decorators
 74/// var timedExecutor = new TimeoutExecutor(
 75///     new IterativeLoopStageExecutor(loop, optionsFactory),
 76///     TimeSpan.FromMinutes(10));
 77/// </code>
 78/// </example>
 79[DoNotAutoRegister]
 80public sealed class IterativeLoopStageExecutor : IStageExecutor
 81{
 82    private readonly IIterativeAgentLoop _loop;
 83    private readonly Func<StageExecutionContext, IterativeLoopOptions> _optionsFactory;
 84    private readonly Func<StageExecutionContext, IterativeContext>? _contextFactory;
 85    private readonly Func<IterativeLoopResult, StageExecutionContext, StageTermination?>? _onLoopCompleted;
 86    private readonly Func<IterativeLoopResult, bool>? _shouldTreatAsSuccess;
 87    private readonly FailureDisposition _failureDisposition;
 88
 89    /// <summary>
 90    /// Initializes a new <see cref="IterativeLoopStageExecutor"/>.
 91    /// </summary>
 92    /// <param name="loop">The iterative agent loop to execute.</param>
 93    /// <param name="optionsFactory">
 94    /// Factory that produces the <see cref="IterativeLoopOptions"/> from the current stage
 95    /// context. Called once per execution — callers configure instructions, tools, prompt
 96    /// factory, iteration limits, and all other loop settings here.
 97    /// </param>
 98    /// <param name="contextFactory">
 99    /// Optional factory that produces the <see cref="IterativeContext"/> from the current stage
 100    /// context. When <see langword="null"/> (the default), the executor creates an
 101    /// <see cref="IterativeContext"/> using <see cref="StageExecutionContext.Workspace"/>.
 102    /// Provide a factory to pre-populate <see cref="IterativeContext.State"/> or use a
 103    /// different workspace.
 104    /// </param>
 105    /// <param name="onLoopCompleted">
 106    /// Optional callback invoked immediately after the loop completes, before result mapping.
 107    /// Receives the raw <see cref="IterativeLoopResult"/> and the <see cref="StageExecutionContext"/>.
 108    /// May return a <see cref="StageTermination"/> to override the framework-mapped default
 109    /// (e.g. to attach app narrative as a <see cref="StageTermination.Custom"/> case);
 110    /// returning <see langword="null"/> uses the framework default mapped from
 111    /// <see cref="IterativeLoopResult.Termination"/>.
 112    /// Called on both success and failure paths. Not called if the loop throws an exception.
 113    /// </param>
 114    /// <param name="shouldTreatAsSuccess">
 115    /// Optional predicate evaluated when the loop result has
 116    /// <see cref="IterativeLoopResult.Succeeded"/> = <see langword="false"/>. When the
 117    /// predicate returns <see langword="true"/>, the executor treats the result as a success.
 118    /// Use this for termination reasons like <see cref="TerminationReason.MaxIterationsReached"/>
 119    /// that are acceptable in the caller's domain. The reported
 120    /// <see cref="StageExecutionResult.Termination"/> still reflects the loop's actual
 121    /// termination — only the success/failure outcome is flipped.
 122    /// Not called when the loop already succeeded.
 123    /// </param>
 124    /// <param name="failureDisposition">
 125    /// The <see cref="FailureDisposition"/> applied to failed results. Defaults to
 126    /// <see cref="FailureDisposition.AbortPipeline"/>. Set to
 127    /// <see cref="FailureDisposition.ContinueAdvisory"/> for stages whose failure should
 128    /// not halt the pipeline.
 129    /// </param>
 75130    public IterativeLoopStageExecutor(
 75131        IIterativeAgentLoop loop,
 75132        Func<StageExecutionContext, IterativeLoopOptions> optionsFactory,
 75133        Func<StageExecutionContext, IterativeContext>? contextFactory = null,
 75134        Func<IterativeLoopResult, StageExecutionContext, StageTermination?>? onLoopCompleted = null,
 75135        Func<IterativeLoopResult, bool>? shouldTreatAsSuccess = null,
 75136        FailureDisposition failureDisposition = FailureDisposition.AbortPipeline)
 137    {
 75138        _loop = loop;
 75139        _optionsFactory = optionsFactory;
 75140        _contextFactory = contextFactory;
 75141        _onLoopCompleted = onLoopCompleted;
 75142        _shouldTreatAsSuccess = shouldTreatAsSuccess;
 75143        _failureDisposition = failureDisposition;
 75144    }
 145
 146    /// <inheritdoc />
 147    public async Task<StageExecutionResult> ExecuteAsync(
 148        StageExecutionContext context,
 149        CancellationToken cancellationToken)
 150    {
 75151        var options = _optionsFactory(context);
 74152        var iterativeContext = _contextFactory?.Invoke(context)
 74153            ?? new IterativeContext { Workspace = context.Workspace };
 154
 73155        using (context.DiagnosticsAccessor.BeginCapture())
 156        {
 73157            var loopResult = await _loop.RunAsync(options, iterativeContext, cancellationToken);
 70158            var diagnostics = loopResult.Diagnostics
 70159                ?? context.DiagnosticsAccessor.LastRunDiagnostics;
 160
 70161            var mappedTermination = MapTermination(loopResult);
 70162            var overriddenTermination = _onLoopCompleted?.Invoke(loopResult, context);
 70163            var termination = overriddenTermination ?? mappedTermination;
 164
 70165            var succeeded = loopResult.Succeeded
 70166                || (_shouldTreatAsSuccess?.Invoke(loopResult) == true);
 167
 70168            if (succeeded)
 169            {
 42170                return StageExecutionResult.Success(
 42171                    context.StageName,
 42172                    diagnostics,
 42173                    loopResult.FinalResponse?.Text,
 42174                    termination: termination);
 175            }
 176
 28177            var failureException = new InvalidOperationException(
 28178                $"{context.StageName} terminated [{loopResult.Termination}] after " +
 28179                $"{loopResult.Iterations.Count} iteration(s): {loopResult.ErrorMessage}");
 28180            return StageExecutionResult.Failed(
 28181                context.StageName,
 28182                failureException,
 28183                diagnostics,
 28184                _failureDisposition,
 28185                termination: termination);
 186        }
 70187    }
 188
 189    private static StageTermination MapTermination(IterativeLoopResult loopResult)
 190    {
 70191        var cfg = loopResult.Configuration;
 70192        return loopResult.Termination switch
 70193        {
 19194            TerminationReason.Completed => new StageTermination.Completed(),
 4195            TerminationReason.NaturalCompletion => new StageTermination.NaturalCompletion(),
 1196            TerminationReason.CompletedEarlyAfterToolCall => new StageTermination.CompletedEarlyAfterToolCall(),
 13197            TerminationReason.MaxIterationsReached => new StageTermination.MaxIterationsReached(
 13198                Limit: cfg.MaxIterations,
 13199                IterationsUsed: loopResult.Iterations.Count),
 5200            TerminationReason.MaxToolCallsReached => new StageTermination.MaxToolCallsReached(
 5201                Limit: cfg.MaxTotalToolCalls ?? int.MaxValue,
 17202                ToolCallsUsed: loopResult.Iterations.Sum(i => i.ToolCallCount)),
 5203            TerminationReason.BudgetPressure => new StageTermination.BudgetPressure(
 5204                Threshold: cfg.BudgetPressureThreshold),
 4205            TerminationReason.Cancelled => new StageTermination.Cancelled(),
 9206            TerminationReason.Error => new StageTermination.Failed(
 9207                new InvalidOperationException(loopResult.ErrorMessage ?? "loop reported error")),
 10208            TerminationReason.StallDetected => new StageTermination.StallDetected(
 10209                ConsecutiveThreshold: cfg.StallDetection?.ConsecutiveThreshold),
 0210            _ => throw new InvalidOperationException(
 0211                $"Unknown TerminationReason value '{loopResult.Termination}' — add a mapping arm to {nameof(IterativeLoo
 70212        };
 213    }
 214}