< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Iterative.IterativeAgentLoop
Assembly: NexusLabs.Needlr.AgentFramework
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/Iterative/IterativeAgentLoop.cs
Line coverage
97%
Covered lines: 534
Uncovered lines: 14
Coverable lines: 548
Total lines: 911
Line coverage: 97.4%
Branch coverage
91%
Covered branches: 194
Total branches: 212
Branch coverage: 91.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.ctor(...)100%11100%
RunAsync()95.71%14014097.12%
RecordPartialIteration(...)75%1212100%
ExecuteToolCallsAsync()85.71%565697.94%
InvokeHookAsync()100%1175%
InvokeHookAsync()100%11100%
ToReadOnly(...)75%44100%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/Iterative/IterativeAgentLoop.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Text.Json;
 3
 4using Microsoft.Extensions.AI;
 5
 6using NexusLabs.Needlr.AgentFramework.Budget;
 7using NexusLabs.Needlr.AgentFramework.Context;
 8using NexusLabs.Needlr.AgentFramework.Diagnostics;
 9using NexusLabs.Needlr.AgentFramework.Progress;
 10
 11namespace NexusLabs.Needlr.AgentFramework.Iterative;
 12
 13/// <summary>
 14/// Default implementation of <see cref="IIterativeAgentLoop"/> that runs an external loop
 15/// with fresh prompts per iteration, bypassing <c>FunctionInvokingChatClient</c>'s
 16/// accumulating conversation history.
 17/// </summary>
 18/// <remarks>
 19/// <para>
 20/// Internally wraps the chat client with <see cref="DiagnosticsChatClientMiddleware"/>,
 21/// which is the single writer for <see cref="ChatCompletionDiagnostics"/>. The loop
 22/// itself writes <see cref="ToolCallDiagnostics"/> and OTel metrics for tool calls
 23/// and run lifecycle. Do not add external chat-completion recording middleware when
 24/// using this loop — it will produce duplicates.
 25/// </para>
 26/// </remarks>
 27[DoNotAutoRegister]
 28internal sealed class IterativeAgentLoop : IIterativeAgentLoop
 29{
 30    private readonly IChatClientAccessor _chatClientAccessor;
 31    private readonly IAgentDiagnosticsWriter? _diagnosticsWriter;
 32    private readonly IAgentExecutionContextAccessor? _executionContextAccessor;
 33    private readonly IProgressReporterAccessor? _progressReporterAccessor;
 34    private readonly ITokenBudgetTracker? _budgetTracker;
 35    private readonly IAgentMetrics? _metrics;
 36    private readonly IGenAiTokenMetrics? _genAiTokenMetrics;
 37    private readonly ChatCompletionActivityMode _activityMode;
 38
 13339    internal IterativeAgentLoop(
 13340        IChatClientAccessor chatClientAccessor,
 13341        IAgentDiagnosticsWriter? diagnosticsWriter = null,
 13342        IAgentExecutionContextAccessor? executionContextAccessor = null,
 13343        IProgressReporterAccessor? progressReporterAccessor = null,
 13344        ITokenBudgetTracker? budgetTracker = null,
 13345        IAgentMetrics? metrics = null,
 13346        ChatCompletionActivityMode activityMode = ChatCompletionActivityMode.Always,
 13347        IGenAiTokenMetrics? genAiTokenMetrics = null)
 48    {
 13349        _chatClientAccessor = chatClientAccessor;
 13350        _diagnosticsWriter = diagnosticsWriter;
 13351        _executionContextAccessor = executionContextAccessor;
 13352        _progressReporterAccessor = progressReporterAccessor;
 13353        _budgetTracker = budgetTracker;
 13354        _metrics = metrics;
 13355        _genAiTokenMetrics = genAiTokenMetrics;
 13356        _activityMode = activityMode;
 13357    }
 58
 59    /// <summary>
 60    /// Sentinel wrapper so lifecycle hook exceptions escape the framework catch-all.
 61    /// </summary>
 162    private sealed class LifecycleHookException(Exception inner) : Exception(inner.Message, inner);
 63
 64    public async Task<IterativeLoopResult> RunAsync(
 65        IterativeLoopOptions options,
 66        IterativeContext context,
 67        CancellationToken cancellationToken = default)
 68    {
 12769        ArgumentNullException.ThrowIfNull(options);
 12770        ArgumentNullException.ThrowIfNull(context);
 71
 12772        var chatClient = _chatClientAccessor.ChatClient;
 73
 74        // Apply chat reducer if configured (innermost middleware)
 75#pragma warning disable MEAI001 // ReducingChatClient is experimental
 12776        if (options.ChatReducer is { } reducer)
 77        {
 078            chatClient = new ReducingChatClient(chatClient, reducer);
 79        }
 80#pragma warning restore MEAI001
 81
 82        // Apply per-loop middleware if configured (wraps the reducer if both are set)
 12783        if (options.ChatClientFactory is { } loopClientFactory)
 84        {
 485            chatClient = loopClientFactory(chatClient);
 86        }
 87
 88        // Install diagnostics recording middleware only when the pipeline does
 89        // not already contain one. UsingDiagnostics(), a per-loop factory, or
 90        // manual wiring may have already installed a DiagnosticsRecordingChatClient.
 91        // Installing a second instance would cause every ChatCompletion to be
 92        // recorded twice, inflating token counts by 2×.
 93        //
 94        // Detection uses MEAI's GetService<T>() which walks the DelegatingChatClient
 95        // chain, so it works regardless of where the middleware was installed.
 12796        if (chatClient.GetService<DiagnosticsRecordingChatClient>() is null)
 97        {
 11498            var chatMiddleware = new DiagnosticsChatClientMiddleware(_metrics, _progressReporterAccessor, _activityMode,
 11499            chatClient = new DiagnosticsRecordingChatClient(chatClient, chatMiddleware);
 100        }
 101
 127102        var iterations = new List<IterationRecord>();
 127103        ChatResponse? finalResponse = null;
 127104        var succeeded = true;
 127105        string? errorMessage = null;
 127106        var termination = TerminationReason.Completed;
 127107        int totalToolCalls = 0;
 108
 127109        var diagnosticsBuilder = AgentRunDiagnosticsBuilder.StartNew(options.LoopName);
 127110        diagnosticsBuilder.SetExecutionMode("IterativeLoop");
 127111        _metrics?.RecordRunStarted(options.LoopName);
 112
 113        // Bridge: if an execution context accessor is available, set up a scope
 114        // so that DI-resolved tools can access the workspace via
 115        // IAgentExecutionContextAccessor.Current.GetRequiredWorkspace().
 127116        IDisposable? executionContextScope = null;
 127117        if (_executionContextAccessor != null)
 118        {
 10119            var executionContext = options.ExecutionContext
 10120                ?? new AgentExecutionContext(
 10121                    UserId: "iterative-loop",
 10122                    OrchestrationId: options.LoopName,
 10123                    Workspace: context.Workspace);
 10124            executionContextScope = _executionContextAccessor.BeginScope(executionContext);
 125        }
 126
 127        // Track in-progress iteration state so catch handlers can record
 128        // partial IterationRecords when interrupted mid-iteration.
 127129        var currentIterationIndex = -1;
 127130        List<ToolCallResult>? currentIterationToolCalls = null;
 127131        Stopwatch? currentIterationStopwatch = null;
 127132        long currentIterationInputTokens = 0;
 127133        long currentIterationOutputTokens = 0;
 127134        long currentIterationTotalTokens = 0;
 127135        long currentIterationCachedInputTokens = 0;
 127136        long currentIterationReasoningTokens = 0;
 127137        int currentIterationLlmCallCount = 0;
 138
 139        try
 140        {
 127141            context.CancellationToken = cancellationToken;
 142
 374143            for (int i = 0; i < options.MaxIterations; i++)
 144            {
 179145                cancellationToken.ThrowIfCancellationRequested();
 146
 176147                context.Iteration = i;
 176148                currentIterationIndex = i;
 176149                currentIterationInputTokens = 0;
 176150                currentIterationOutputTokens = 0;
 176151                currentIterationTotalTokens = 0;
 176152                currentIterationCachedInputTokens = 0;
 176153                currentIterationReasoningTokens = 0;
 176154                currentIterationLlmCallCount = 0;
 155
 156                // Hook: iteration start (wrapped to escape catch-all)
 176157                if (options.OnIterationStart != null)
 158                {
 2159                    await InvokeHookAsync(options.OnIterationStart, i, context).ConfigureAwait(false);
 160                }
 161
 162                // Build fresh prompt from workspace state
 175163                var budgetPressureTriggered = false;
 164                string userPrompt;
 165                try
 166                {
 175167                    userPrompt = options.PromptFactory(context);
 175168                }
 0169                catch (Exception ex)
 170                {
 0171                    succeeded = false;
 0172                    termination = TerminationReason.Error;
 0173                    errorMessage = $"Prompt factory failed on iteration {i}: {ex.Message}";
 0174                    diagnosticsBuilder.RecordFailure(errorMessage);
 0175                    break;
 176                }
 177
 178                // Budget pressure: if token usage is at or above the threshold,
 179                // prepend the finalization instruction and mark this as the last iteration.
 175180                if (options.BudgetPressureThreshold is { } threshold
 175181                    && _budgetTracker is { MaxTokens: > 0 } tracker)
 182                {
 2183                    var usage = (double)tracker.CurrentTokens / tracker.MaxTokens.Value;
 2184                    if (usage >= threshold)
 185                    {
 0186                        userPrompt = options.BudgetPressureInstruction + "\n\n" + userPrompt;
 0187                        budgetPressureTriggered = true;
 188                    }
 189                }
 190
 175191                var iterationStopwatch = Stopwatch.StartNew();
 175192                currentIterationStopwatch = iterationStopwatch;
 175193                var iterationToolCalls = new List<ToolCallResult>();
 175194                currentIterationToolCalls = iterationToolCalls;
 175195                ChatResponse? iterationResponse = null;
 196
 197                // Build messages — always just [system, user], no history
 175198                var messages = new List<ChatMessage>
 175199                {
 175200                    new(ChatRole.System, options.Instructions),
 175201                    new(ChatRole.User, userPrompt),
 175202                };
 203
 175204                var effectiveTools = options.ToolFilter is { } filter
 175205                    ? filter(i, context, options.Tools)
 175206                    : options.Tools;
 207
 175208                var chatOptions = new ChatOptions
 175209                {
 175210                    Tools = effectiveTools.Cast<AITool>().ToList(),
 175211                };
 212
 213                // Execute rounds within this iteration based on ToolResultMode
 175214                var maxRounds = options.ToolResultMode switch
 175215                {
 49216                    ToolResultMode.SingleCall => 1,
 109217                    ToolResultMode.OneRoundTrip => 2,
 17218                    ToolResultMode.MultiRound => options.MaxToolRoundsPerIteration,
 0219                    _ => 1,
 175220                };
 221
 572222                for (int round = 0; round < maxRounds; round++)
 223                {
 252224                    cancellationToken.ThrowIfCancellationRequested();
 225
 226                    // Check budget pressure between rounds (not just per iteration)
 252227                    if (round > 0
 252228                        && !budgetPressureTriggered
 252229                        && options.BudgetPressureThreshold is { } roundThreshold
 252230                        && _budgetTracker is { MaxTokens: > 0 } roundTracker)
 231                    {
 2232                        var roundUsage = (double)roundTracker.CurrentTokens / roundTracker.MaxTokens.Value;
 2233                        if (roundUsage >= roundThreshold)
 234                        {
 1235                            budgetPressureTriggered = true;
 1236                            break;
 237                        }
 238                    }
 239
 240                    ChatResponse response;
 241
 242                    try
 243                    {
 251244                        response = await chatClient.GetResponseAsync(
 251245                            messages, chatOptions, cancellationToken).ConfigureAwait(false);
 242246                    }
 6247                    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 248                    {
 1249                        throw; // genuine cancellation — let outer handler terminate the loop
 250                    }
 8251                    catch (Exception)
 252                    {
 253                        // Chat completion diagnostics are recorded by the middleware
 254                        // wrapping the chat client — the loop does not record them.
 8255                        diagnosticsBuilder.RecordInputMessageCount(messages.Count);
 8256                        throw;
 257                    }
 258
 242259                    currentIterationLlmCallCount++;
 260
 261                    // Track tokens (input/output/total + cached/reasoning when reported)
 726262                    long callInput = 0, callOutput = 0, callTotal = 0;
 242263                    if (response.Usage is { } usage)
 264                    {
 94265                        callInput = usage.InputTokenCount ?? 0;
 94266                        callOutput = usage.OutputTokenCount ?? 0;
 94267                        callTotal = usage.TotalTokenCount ?? 0;
 94268                        currentIterationInputTokens += callInput;
 94269                        currentIterationOutputTokens += callOutput;
 94270                        currentIterationTotalTokens += callTotal;
 94271                        currentIterationCachedInputTokens +=
 94272                            usage.CachedInputTokenCount
 94273                            ?? usage.AdditionalCounts?.GetValueOrDefault("CachedInputTokens")
 94274                            ?? 0;
 94275                        currentIterationReasoningTokens +=
 94276                            usage.ReasoningTokenCount
 94277                            ?? usage.AdditionalCounts?.GetValueOrDefault("ReasoningTokens")
 94278                            ?? 0;
 279                    }
 280
 242281                    var responseMessageCount = response.Messages.Count;
 282
 283                    // Chat completion diagnostics are recorded by the middleware
 284                    // wrapping the chat client — the loop does not record them.
 242285                    diagnosticsBuilder.RecordInputMessageCount(messages.Count);
 242286                    diagnosticsBuilder.RecordOutputMessageCount(responseMessageCount);
 287
 288                    // Check for tool calls in response
 242289                    var functionCalls = response.Messages
 242290                        .SelectMany(m => m.Contents.OfType<FunctionCallContent>())
 242291                        .ToList();
 292
 242293                    if (functionCalls.Count == 0)
 294                    {
 295                        // Model produced text — natural termination for this iteration.
 296                        // Capture the full ChatResponse to preserve messages, usage, and
 297                        // any other metadata for downstream consumers and evaluation.
 89298                        iterationResponse = response;
 89299                        break;
 300                    }
 301
 302                    // Execute tool calls — limit to remaining allowance if MaxTotalToolCalls is set
 153303                    var remainingAllowance = options.MaxTotalToolCalls.HasValue
 153304                        ? options.MaxTotalToolCalls.Value - totalToolCalls
 153305                        : (int?)null;
 306
 153307                    var callsToExecute = remainingAllowance.HasValue && remainingAllowance.Value < functionCalls.Count
 153308                        ? functionCalls.Take(remainingAllowance.Value).ToList()
 153309                        : functionCalls;
 310
 311                    // Build per-call early exit check for AfterEachToolCall mode
 153312                    Func<List<ToolCallResult>, bool>? perCallEarlyExitCheck = null;
 153313                    if (options.CheckCompletionAfterToolCalls == ToolCompletionCheckMode.AfterEachToolCall
 153314                        && options.IsComplete is { } perCallIsComplete)
 315                    {
 5316                        perCallEarlyExitCheck = partialResults =>
 5317                        {
 9318                            context.LastToolResults = partialResults;
 9319                            return perCallIsComplete(context);
 5320                        };
 321                    }
 322
 153323                    var (roundResults, earlyExitFromToolCall) = await ExecuteToolCallsAsync(
 153324                        callsToExecute, options.Tools, diagnosticsBuilder,
 153325                        i, options.OnToolCall, _progressReporterAccessor,
 153326                        _metrics, perCallEarlyExitCheck, cancellationToken)
 153327                        .ConfigureAwait(false);
 153328                    iterationToolCalls.AddRange(roundResults);
 153329                    totalToolCalls += roundResults.Count;
 330
 331                    // Early completion check (fires before MaxTotalToolCalls so completion wins)
 153332                    if (earlyExitFromToolCall)
 333                    {
 4334                        termination = TerminationReason.CompletedEarlyAfterToolCall;
 4335                        break;
 336                    }
 337
 149338                    if (options.CheckCompletionAfterToolCalls == ToolCompletionCheckMode.AfterToolRounds
 149339                        || options.CheckCompletionAfterToolCalls == ToolCompletionCheckMode.AfterEachToolCall)
 340                    {
 7341                        if (options.IsComplete is { } earlyCheck)
 342                        {
 7343                            context.LastToolResults = iterationToolCalls;
 7344                            if (earlyCheck(context))
 345                            {
 6346                                termination = TerminationReason.CompletedEarlyAfterToolCall;
 6347                                break;
 348                            }
 349                        }
 350                    }
 351
 352                    // Check MaxTotalToolCalls guard
 143353                    if (options.MaxTotalToolCalls is { } maxCalls && totalToolCalls >= maxCalls)
 354                    {
 1355                        termination = TerminationReason.MaxToolCallsReached;
 1356                        succeeded = false;
 1357                        errorMessage = $"Cumulative tool call count ({totalToolCalls}) reached MaxTotalToolCalls ({maxCa
 1358                        diagnosticsBuilder.RecordFailure(errorMessage);
 1359                        break;
 360                    }
 361
 362                    // For SingleCall mode, don't send results back — just store them
 142363                    if (options.ToolResultMode == ToolResultMode.SingleCall)
 364                    {
 365                        break;
 366                    }
 367
 368                    // For OneRoundTrip/MultiRound, send results back to model
 369                    // Add assistant message with tool calls
 111370                    var assistantMessage = new ChatMessage(ChatRole.Assistant,
 225371                        functionCalls.Select(fc => (AIContent)fc).ToList());
 111372                    messages.Add(assistantMessage);
 373
 374                    // Add tool result messages
 450375                    foreach (var (fc, result) in functionCalls.Zip(roundResults))
 376                    {
 114377                        var resultContent = result.Succeeded
 114378                            ? ToolResultSerializer.Serialize(result.Result)
 114379                            : $"Error: {result.ErrorMessage}";
 380
 114381                        messages.Add(new ChatMessage(ChatRole.Tool,
 114382                            [new FunctionResultContent(fc.CallId, resultContent)]));
 383                    }
 384
 385                    // For OneRoundTrip, if this was the first round (round 0),
 386                    // we'll do ONE more LLM call. If it's round 1, we're done.
 387                    // For MultiRound, we continue until maxRounds or text response.
 111388                }
 389
 390                // If a guard triggered termination inside the round loop, break outer loop too
 166391                if (termination == TerminationReason.MaxToolCallsReached)
 392                {
 393                    // Still record the partial iteration
 1394                    iterationStopwatch.Stop();
 1395                    iterations.Add(new IterationRecord(
 1396                        Iteration: i,
 1397                        ToolCalls: iterationToolCalls,
 1398                        FinalResponse: iterationResponse,
 1399                        Tokens: new TokenUsage(
 1400                            InputTokens: currentIterationInputTokens,
 1401                            OutputTokens: currentIterationOutputTokens,
 1402                            TotalTokens: currentIterationTotalTokens,
 1403                            CachedInputTokens: currentIterationCachedInputTokens,
 1404                            ReasoningTokens: currentIterationReasoningTokens),
 1405                        Duration: iterationStopwatch.Elapsed,
 1406                        LlmCallCount: currentIterationLlmCallCount,
 1407                        ToolCallCount: iterationToolCalls.Count));
 1408                    context.LastToolResults = iterationToolCalls;
 1409                    break;
 410                }
 411
 412                // Early completion after tool call — record iteration, fire hooks, then exit
 165413                if (termination == TerminationReason.CompletedEarlyAfterToolCall)
 414                {
 10415                    iterationStopwatch.Stop();
 10416                    iterations.Add(new IterationRecord(
 10417                        Iteration: i,
 10418                        ToolCalls: iterationToolCalls,
 10419                        FinalResponse: iterationResponse,
 10420                        Tokens: new TokenUsage(
 10421                            InputTokens: currentIterationInputTokens,
 10422                            OutputTokens: currentIterationOutputTokens,
 10423                            TotalTokens: currentIterationTotalTokens,
 10424                            CachedInputTokens: currentIterationCachedInputTokens,
 10425                            ReasoningTokens: currentIterationReasoningTokens),
 10426                        Duration: iterationStopwatch.Elapsed,
 10427                        LlmCallCount: currentIterationLlmCallCount,
 10428                        ToolCallCount: iterationToolCalls.Count));
 10429                    context.LastToolResults = iterationToolCalls;
 430
 10431                    if (options.OnIterationEnd != null)
 432                    {
 1433                        await InvokeHookAsync(options.OnIterationEnd, iterations[^1]).ConfigureAwait(false);
 434                    }
 435
 1436                    break;
 437                }
 438
 155439                iterationStopwatch.Stop();
 440
 155441                var tokenUsage = new TokenUsage(
 155442                    InputTokens: currentIterationInputTokens,
 155443                    OutputTokens: currentIterationOutputTokens,
 155444                    TotalTokens: currentIterationTotalTokens,
 155445                    CachedInputTokens: currentIterationCachedInputTokens,
 155446                    ReasoningTokens: currentIterationReasoningTokens);
 447
 155448                iterations.Add(new IterationRecord(
 155449                    Iteration: i,
 155450                    ToolCalls: iterationToolCalls,
 155451                    FinalResponse: iterationResponse,
 155452                    Tokens: tokenUsage,
 155453                    Duration: iterationStopwatch.Elapsed,
 155454                    LlmCallCount: currentIterationLlmCallCount,
 155455                    ToolCallCount: iterationToolCalls.Count));
 456
 457                // Update context for next iteration
 155458                context.LastToolResults = iterationToolCalls;
 459
 460                // Hook: iteration end (wrapped to escape catch-all)
 155461                if (options.OnIterationEnd != null)
 462                {
 4463                    await InvokeHookAsync(options.OnIterationEnd, iterations[^1]).ConfigureAwait(false);
 464                }
 465
 466                // Stall detection — compare consecutive iterations
 155467                if (options.StallDetection is { } stallOpts && iterations.Count >= 2)
 468                {
 13469                    var currentTokens = iterations[^1].Tokens.TotalTokens;
 13470                    var consecutiveSimilar = 0;
 471
 38472                    for (int s = iterations.Count - 2; s >= 0; s--)
 473                    {
 15474                        var prevTokens = iterations[s].Tokens.TotalTokens;
 15475                        if (prevTokens > 0)
 476                        {
 15477                            var delta = Math.Abs(currentTokens - prevTokens) / (double)prevTokens;
 15478                            if (delta <= stallOpts.TolerancePercent)
 479                            {
 6480                                consecutiveSimilar++;
 6481                                currentTokens = prevTokens;
 482                            }
 483                            else
 484                            {
 485                                break;
 486                            }
 487                        }
 488                        else
 489                        {
 490                            break;
 491                        }
 492                    }
 493
 13494                    if (consecutiveSimilar >= stallOpts.ConsecutiveThreshold - 1)
 495                    {
 2496                        termination = TerminationReason.StallDetected;
 2497                        succeeded = false;
 2498                        errorMessage = $"Stall detected: {consecutiveSimilar + 1} consecutive iterations " +
 2499                            $"with similar token counts (~{iterations[^1].Tokens.TotalTokens} tokens, " +
 2500                            $"tolerance {stallOpts.TolerancePercent:P0}).";
 2501                        diagnosticsBuilder.RecordFailure(errorMessage);
 2502                        break;
 503                    }
 504                }
 505
 506                // Check IsComplete predicate
 153507                if (options.IsComplete?.Invoke(context) == true)
 508                {
 24509                    termination = TerminationReason.Completed;
 24510                    break;
 511                }
 512
 513                // Budget pressure: this was the finalization iteration — stop now
 129514                if (budgetPressureTriggered)
 515                {
 1516                    termination = TerminationReason.BudgetPressure;
 1517                    break;
 518                }
 519
 520                // If model produced text (no tool calls), the loop is done
 128521                if (iterationResponse != null)
 522                {
 68523                    finalResponse = iterationResponse;
 68524                    termination = TerminationReason.NaturalCompletion;
 68525                    break;
 526                }
 60527            }
 528
 529            // If the loop exhausted MaxIterations without IsComplete returning true
 530            // and without a text response, that's a failure — the agent didn't finish.
 114531            if (termination == TerminationReason.Completed
 114532                && finalResponse == null
 114533                && options.IsComplete?.Invoke(context) != true
 114534                && iterations.Count >= options.MaxIterations)
 535            {
 8536                succeeded = false;
 8537                termination = TerminationReason.MaxIterationsReached;
 8538                errorMessage = $"Loop exhausted {options.MaxIterations} iterations without completing. "
 8539                    + "The IsComplete predicate never returned true and the model never produced a text response.";
 8540                diagnosticsBuilder.RecordFailure(errorMessage);
 541            }
 114542        }
 9543        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 544        {
 4545            succeeded = false;
 4546            termination = TerminationReason.Cancelled;
 4547            errorMessage = $"Loop was cancelled after {iterations.Count} completed iteration(s).";
 4548            diagnosticsBuilder.RecordFailure(errorMessage);
 4549            RecordPartialIteration(
 4550                iterations,
 4551                currentIterationIndex,
 4552                currentIterationToolCalls,
 4553                currentIterationStopwatch,
 4554                currentIterationInputTokens,
 4555                currentIterationOutputTokens,
 4556                currentIterationTotalTokens,
 4557                currentIterationCachedInputTokens,
 4558                currentIterationReasoningTokens,
 4559                currentIterationLlmCallCount);
 4560        }
 5561        catch (OperationCanceledException ex)
 562        {
 563            // HTTP timeout (TaskCanceledException with TimeoutException inner)
 564            // or other non-user cancellation — report as Error, not Cancelled.
 5565            succeeded = false;
 5566            termination = TerminationReason.Error;
 5567            errorMessage = ex.InnerException is TimeoutException
 5568                ? $"Chat completion timed out on iteration {iterations.Count + 1}: {ex.InnerException.Message}"
 5569                : $"Operation cancelled (not by caller) on iteration {iterations.Count + 1}: {ex.Message}";
 5570            diagnosticsBuilder.RecordFailure(errorMessage);
 5571            RecordPartialIteration(
 5572                iterations,
 5573                currentIterationIndex,
 5574                currentIterationToolCalls,
 5575                currentIterationStopwatch,
 5576                currentIterationInputTokens,
 5577                currentIterationOutputTokens,
 5578                currentIterationTotalTokens,
 5579                currentIterationCachedInputTokens,
 5580                currentIterationReasoningTokens,
 5581                currentIterationLlmCallCount);
 5582        }
 583        catch (LifecycleHookException hookEx)
 584        {
 585            // Lifecycle hook exceptions propagate to the caller — they are
 586            // user-controlled code and should not be silently swallowed.
 1587            throw hookEx.InnerException!;
 588        }
 3589        catch (Exception ex)
 590        {
 3591            succeeded = false;
 3592            termination = TerminationReason.Error;
 3593            errorMessage = ex.Message;
 3594            diagnosticsBuilder.RecordFailure(errorMessage);
 3595            RecordPartialIteration(
 3596                iterations,
 3597                currentIterationIndex,
 3598                currentIterationToolCalls,
 3599                currentIterationStopwatch,
 3600                currentIterationInputTokens,
 3601                currentIterationOutputTokens,
 3602                currentIterationTotalTokens,
 3603                currentIterationCachedInputTokens,
 3604                currentIterationReasoningTokens,
 3605                currentIterationLlmCallCount);
 3606        }
 607
 126608        if (finalResponse == null && iterations.Count > 0)
 609        {
 610            // Get final response from last iteration if available
 56611            finalResponse = iterations[^1].FinalResponse;
 612        }
 613
 126614        var diagnostics = diagnosticsBuilder.Build();
 126615        diagnosticsBuilder.Dispose();
 126616        _diagnosticsWriter?.Set(diagnostics);
 126617        _metrics?.RecordRunCompleted(diagnostics);
 126618        executionContextScope?.Dispose();
 619
 126620        var configuration = new IterativeLoopConfiguration(
 126621            ToolResultMode: options.ToolResultMode,
 126622            MaxIterations: options.MaxIterations,
 126623            MaxToolRoundsPerIteration: options.MaxToolRoundsPerIteration,
 126624            MaxTotalToolCalls: options.MaxTotalToolCalls,
 126625            BudgetPressureThreshold: options.BudgetPressureThreshold,
 126626            LoopName: options.LoopName,
 126627            CheckCompletionAfterToolCalls: options.CheckCompletionAfterToolCalls,
 126628            StallDetection: options.StallDetection);
 629
 126630        return new IterativeLoopResult(
 126631            Iterations: iterations,
 126632            FinalResponse: finalResponse,
 126633            Diagnostics: diagnostics,
 126634            Succeeded: succeeded,
 126635            ErrorMessage: errorMessage,
 126636            Termination: termination,
 126637            Configuration: configuration);
 126638    }
 639
 640    /// <summary>
 641    /// Records a partial <see cref="IterationRecord"/> for an iteration that was
 642    /// interrupted by an exception. Captures whatever tool calls, timing data,
 643    /// and per-iteration token counters were accumulated before the interruption.
 644    /// </summary>
 645    private static void RecordPartialIteration(
 646        List<IterationRecord> iterations,
 647        int currentIterationIndex,
 648        List<ToolCallResult>? toolCalls,
 649        Stopwatch? stopwatch,
 650        long inputTokens,
 651        long outputTokens,
 652        long totalTokens,
 653        long cachedInputTokens,
 654        long reasoningTokens,
 655        int llmCallCount)
 656    {
 12657        if (currentIterationIndex < 0 || currentIterationIndex < iterations.Count)
 658        {
 3659            return;
 660        }
 661
 9662        stopwatch?.Stop();
 9663        iterations.Add(new IterationRecord(
 9664            Iteration: currentIterationIndex,
 9665            ToolCalls: toolCalls ?? [],
 9666            FinalResponse: null,
 9667            Tokens: new TokenUsage(
 9668                InputTokens: inputTokens,
 9669                OutputTokens: outputTokens,
 9670                TotalTokens: totalTokens,
 9671                CachedInputTokens: cachedInputTokens,
 9672                ReasoningTokens: reasoningTokens),
 9673            Duration: stopwatch?.Elapsed ?? TimeSpan.Zero,
 9674            LlmCallCount: llmCallCount,
 9675            ToolCallCount: toolCalls?.Count ?? 0));
 9676    }
 677
 678    private static async Task<(List<ToolCallResult> Results, bool EarlyExit)> ExecuteToolCallsAsync(
 679        List<FunctionCallContent> functionCalls,
 680        IReadOnlyList<AITool> tools,
 681        AgentRunDiagnosticsBuilder diagnosticsBuilder,
 682        int iteration,
 683        Func<int, ToolCallResult, Task>? onToolCall,
 684        IProgressReporterAccessor? progressAccessor,
 685        IAgentMetrics? metrics,
 686        Func<List<ToolCallResult>, bool>? earlyExitCheck,
 687        CancellationToken cancellationToken)
 688    {
 153689        var toolMap = tools.OfType<AIFunction>()
 318690            .ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase);
 691
 153692        var results = new List<ToolCallResult>();
 153693        var reporter = progressAccessor?.Current;
 694
 620695        foreach (var fc in functionCalls)
 696        {
 159697            var sequence = diagnosticsBuilder.NextToolCallSequence();
 159698            var startedAt = DateTimeOffset.UtcNow;
 159699            var stopwatch = Stopwatch.StartNew();
 700
 159701            using var activity = metrics?.ActivitySource.StartActivity($"agent.tool {fc.Name}", ActivityKind.Internal);
 159702            activity?.SetTag("agent.tool.name", fc.Name);
 159703            activity?.SetTag("agent.tool.sequence", sequence);
 159704            activity?.SetTag("gen_ai.agent.name", diagnosticsBuilder.AgentName);
 705
 159706            reporter?.Report(new ToolCallStartedEvent(
 159707                Timestamp: startedAt,
 159708                WorkflowId: reporter.WorkflowId,
 159709                AgentId: reporter.AgentId,
 159710                ParentAgentId: null,
 159711                Depth: reporter.Depth,
 159712                SequenceNumber: reporter.NextSequence(),
 159713                ToolName: fc.Name));
 714
 159715            if (!toolMap.TryGetValue(fc.Name, out var function))
 716            {
 2717                stopwatch.Stop();
 2718                var errorResult = new ToolCallResult(
 2719                    FunctionName: fc.Name,
 2720                    Arguments: ToReadOnly(fc.Arguments),
 2721                    Result: null,
 2722                    Duration: stopwatch.Elapsed,
 2723                    Succeeded: false,
 2724                    ErrorMessage: $"Unknown tool: '{fc.Name}'");
 725
 2726                diagnosticsBuilder.AddToolCall(new ToolCallDiagnostics(
 2727                    Sequence: sequence,
 2728                    ToolName: fc.Name,
 2729                    Duration: stopwatch.Elapsed,
 2730                    Succeeded: false,
 2731                    ErrorMessage: errorResult.ErrorMessage,
 2732                    StartedAt: startedAt,
 2733                    CompletedAt: DateTimeOffset.UtcNow,
 2734                    CustomMetrics: null)
 2735                {
 2736                    AgentName = diagnosticsBuilder.AgentName,
 2737                    Arguments = ToReadOnly(fc.Arguments),
 2738                    ArgumentsCharCount = DiagnosticsCharCounter.JsonLength(fc.Arguments),
 2739                });
 2740                metrics?.RecordToolCall(fc.Name, stopwatch.Elapsed, succeeded: false, agentName: diagnosticsBuilder.Agen
 2741                activity?.SetStatus(ActivityStatusCode.Error, errorResult.ErrorMessage);
 2742                activity?.SetTag("status", "failed");
 743
 2744                reporter?.Report(new ToolCallFailedEvent(
 2745                    Timestamp: DateTimeOffset.UtcNow,
 2746                    WorkflowId: reporter.WorkflowId,
 2747                    AgentId: reporter.AgentId,
 2748                    ParentAgentId: null,
 2749                    Depth: reporter.Depth,
 2750                    SequenceNumber: reporter.NextSequence(),
 2751                    ToolName: fc.Name,
 2752                    ErrorMessage: errorResult.ErrorMessage ?? "Unknown tool",
 2753                    Duration: stopwatch.Elapsed));
 754
 2755                results.Add(errorResult);
 756
 2757                if (onToolCall != null)
 758                {
 0759                    await InvokeHookAsync(onToolCall, iteration, errorResult).ConfigureAwait(false);
 760                }
 761
 2762                if (earlyExitCheck != null && earlyExitCheck(results))
 763                {
 0764                    return (results, EarlyExit: true);
 765                }
 766
 2767                continue;
 768            }
 769
 770            try
 771            {
 157772                var result = await function.InvokeAsync(
 157773                    fc.Arguments is { } args ? new AIFunctionArguments(args) : null,
 157774                    cancellationToken).ConfigureAwait(false);
 775
 154776                stopwatch.Stop();
 777
 154778                diagnosticsBuilder.AddToolCall(new ToolCallDiagnostics(
 154779                    Sequence: sequence,
 154780                    ToolName: fc.Name,
 154781                    Duration: stopwatch.Elapsed,
 154782                    Succeeded: true,
 154783                    ErrorMessage: null,
 154784                    StartedAt: startedAt,
 154785                    CompletedAt: DateTimeOffset.UtcNow,
 154786                    CustomMetrics: null)
 154787                {
 154788                    AgentName = diagnosticsBuilder.AgentName,
 154789                    Arguments = ToReadOnly(fc.Arguments),
 154790                    Result = result,
 154791                    ArgumentsCharCount = DiagnosticsCharCounter.JsonLength(fc.Arguments),
 154792                    ResultCharCount = DiagnosticsCharCounter.JsonLength(result),
 154793                });
 154794                metrics?.RecordToolCall(fc.Name, stopwatch.Elapsed, succeeded: true, agentName: diagnosticsBuilder.Agent
 154795                activity?.SetTag("status", "success");
 796
 154797                reporter?.Report(new ToolCallCompletedEvent(
 154798                    Timestamp: DateTimeOffset.UtcNow,
 154799                    WorkflowId: reporter.WorkflowId,
 154800                    AgentId: reporter.AgentId,
 154801                    ParentAgentId: null,
 154802                    Depth: reporter.Depth,
 154803                    SequenceNumber: reporter.NextSequence(),
 154804                    ToolName: fc.Name,
 154805                    Duration: stopwatch.Elapsed,
 154806                    CustomMetrics: null));
 807
 154808                results.Add(new ToolCallResult(
 154809                    FunctionName: fc.Name,
 154810                    Arguments: ToReadOnly(fc.Arguments),
 154811                    Result: result,
 154812                    Duration: stopwatch.Elapsed,
 154813                    Succeeded: true,
 154814                    ErrorMessage: null));
 815
 154816                if (onToolCall != null)
 817                {
 1818                    await InvokeHookAsync(onToolCall, iteration, results[^1]).ConfigureAwait(false);
 819                }
 154820            }
 3821            catch (Exception ex)
 822            {
 3823                stopwatch.Stop();
 824
 3825                diagnosticsBuilder.AddToolCall(new ToolCallDiagnostics(
 3826                    Sequence: sequence,
 3827                    ToolName: fc.Name,
 3828                    Duration: stopwatch.Elapsed,
 3829                    Succeeded: false,
 3830                    ErrorMessage: ex.Message,
 3831                    StartedAt: startedAt,
 3832                    CompletedAt: DateTimeOffset.UtcNow,
 3833                    CustomMetrics: null)
 3834                {
 3835                    AgentName = diagnosticsBuilder.AgentName,
 3836                    Arguments = ToReadOnly(fc.Arguments),
 3837                    ArgumentsCharCount = DiagnosticsCharCounter.JsonLength(fc.Arguments),
 3838                });
 3839                metrics?.RecordToolCall(fc.Name, stopwatch.Elapsed, succeeded: false, agentName: diagnosticsBuilder.Agen
 3840                activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
 3841                activity?.SetTag("status", "failed");
 842
 3843                reporter?.Report(new ToolCallFailedEvent(
 3844                    Timestamp: DateTimeOffset.UtcNow,
 3845                    WorkflowId: reporter.WorkflowId,
 3846                    AgentId: reporter.AgentId,
 3847                    ParentAgentId: null,
 3848                    Depth: reporter.Depth,
 3849                    SequenceNumber: reporter.NextSequence(),
 3850                    ToolName: fc.Name,
 3851                    ErrorMessage: ex.Message,
 3852                    Duration: stopwatch.Elapsed));
 853
 3854                results.Add(new ToolCallResult(
 3855                    FunctionName: fc.Name,
 3856                    Arguments: ToReadOnly(fc.Arguments),
 3857                    Result: null,
 3858                    Duration: stopwatch.Elapsed,
 3859                    Succeeded: false,
 3860                    ErrorMessage: ex.Message));
 861
 3862                if (onToolCall != null)
 863                {
 0864                    await InvokeHookAsync(onToolCall, iteration, results[^1]).ConfigureAwait(false);
 865                }
 866            }
 867
 868            // Per-call early exit check
 157869            if (earlyExitCheck != null && earlyExitCheck(results))
 870            {
 4871                return (results, EarlyExit: true);
 872            }
 153873        }
 874
 149875        return (results, EarlyExit: false);
 153876    }
 877
 878    private static async Task InvokeHookAsync<T>(Func<T, Task> hook, T arg)
 879    {
 880        try
 881        {
 5882            await hook(arg).ConfigureAwait(false);
 5883        }
 884        catch (Exception ex)
 885        {
 0886            throw new LifecycleHookException(ex);
 887        }
 5888    }
 889
 890    private static async Task InvokeHookAsync<T1, T2>(Func<T1, T2, Task> hook, T1 arg1, T2 arg2)
 891    {
 892        try
 893        {
 3894            await hook(arg1, arg2).ConfigureAwait(false);
 2895        }
 896        catch (Exception ex)
 897        {
 1898            throw new LifecycleHookException(ex);
 899        }
 2900    }
 901
 902    private static IReadOnlyDictionary<string, object?> ToReadOnly(
 903        IDictionary<string, object?>? arguments) =>
 318904        arguments is IReadOnlyDictionary<string, object?> ro
 318905            ? ro
 318906            : arguments is not null
 318907                ? new Dictionary<string, object?>(arguments)
 318908                : new Dictionary<string, object?>();
 909
 910
 911}