< 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: 470
Uncovered lines: 14
Coverable lines: 484
Total lines: 840
Line coverage: 97.1%
Branch coverage
92%
Covered branches: 188
Total branches: 204
Branch coverage: 92.1%
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()96.96%13313296.56%
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 ChatCompletionActivityMode _activityMode;
 37
 12838    internal IterativeAgentLoop(
 12839        IChatClientAccessor chatClientAccessor,
 12840        IAgentDiagnosticsWriter? diagnosticsWriter = null,
 12841        IAgentExecutionContextAccessor? executionContextAccessor = null,
 12842        IProgressReporterAccessor? progressReporterAccessor = null,
 12843        ITokenBudgetTracker? budgetTracker = null,
 12844        IAgentMetrics? metrics = null,
 12845        ChatCompletionActivityMode activityMode = ChatCompletionActivityMode.Always)
 46    {
 12847        _chatClientAccessor = chatClientAccessor;
 12848        _diagnosticsWriter = diagnosticsWriter;
 12849        _executionContextAccessor = executionContextAccessor;
 12850        _progressReporterAccessor = progressReporterAccessor;
 12851        _budgetTracker = budgetTracker;
 12852        _metrics = metrics;
 12853        _activityMode = activityMode;
 12854    }
 55
 56    /// <summary>
 57    /// Sentinel wrapper so lifecycle hook exceptions escape the framework catch-all.
 58    /// </summary>
 159    private sealed class LifecycleHookException(Exception inner) : Exception(inner.Message, inner);
 60
 61    public async Task<IterativeLoopResult> RunAsync(
 62        IterativeLoopOptions options,
 63        IterativeContext context,
 64        CancellationToken cancellationToken = default)
 65    {
 12566        ArgumentNullException.ThrowIfNull(options);
 12567        ArgumentNullException.ThrowIfNull(context);
 68
 12569        var chatClient = _chatClientAccessor.ChatClient;
 70
 71        // Apply chat reducer if configured (innermost middleware)
 72#pragma warning disable MEAI001 // ReducingChatClient is experimental
 12573        if (options.ChatReducer is { } reducer)
 74        {
 075            chatClient = new ReducingChatClient(chatClient, reducer);
 76        }
 77#pragma warning restore MEAI001
 78
 79        // Apply per-loop middleware if configured (wraps the reducer if both are set)
 12580        if (options.ChatClientFactory is { } loopClientFactory)
 81        {
 482            chatClient = loopClientFactory(chatClient);
 83        }
 84
 85        // Install diagnostics recording middleware only when the pipeline does
 86        // not already contain one. UsingDiagnostics(), a per-loop factory, or
 87        // manual wiring may have already installed a DiagnosticsRecordingChatClient.
 88        // Installing a second instance would cause every ChatCompletion to be
 89        // recorded twice, inflating token counts by 2×.
 90        //
 91        // Detection uses MEAI's GetService<T>() which walks the DelegatingChatClient
 92        // chain, so it works regardless of where the middleware was installed.
 12593        if (chatClient.GetService<DiagnosticsRecordingChatClient>() is null)
 94        {
 11295            var chatMiddleware = new DiagnosticsChatClientMiddleware(_metrics, _progressReporterAccessor, _activityMode)
 11296            chatClient = new DiagnosticsRecordingChatClient(chatClient, chatMiddleware);
 97        }
 98
 12599        var iterations = new List<IterationRecord>();
 125100        ChatResponse? finalResponse = null;
 125101        var succeeded = true;
 125102        string? errorMessage = null;
 125103        var termination = TerminationReason.Completed;
 125104        int totalToolCalls = 0;
 105
 125106        var diagnosticsBuilder = AgentRunDiagnosticsBuilder.StartNew(options.LoopName);
 125107        diagnosticsBuilder.SetExecutionMode("IterativeLoop");
 125108        _metrics?.RecordRunStarted(options.LoopName);
 109
 110        // Bridge: if an execution context accessor is available, set up a scope
 111        // so that DI-resolved tools can access the workspace via
 112        // IAgentExecutionContextAccessor.Current.GetRequiredWorkspace().
 125113        IDisposable? executionContextScope = null;
 125114        if (_executionContextAccessor != null)
 115        {
 10116            var executionContext = options.ExecutionContext
 10117                ?? new AgentExecutionContext(
 10118                    UserId: "iterative-loop",
 10119                    OrchestrationId: options.LoopName,
 10120                    Workspace: context.Workspace);
 10121            executionContextScope = _executionContextAccessor.BeginScope(executionContext);
 122        }
 123
 124        // Track in-progress iteration state so catch handlers can record
 125        // partial IterationRecords when interrupted mid-iteration.
 125126        var currentIterationIndex = -1;
 125127        List<ToolCallResult>? currentIterationToolCalls = null;
 125128        Stopwatch? currentIterationStopwatch = null;
 129
 130        try
 131        {
 125132            context.CancellationToken = cancellationToken;
 133
 370134            for (int i = 0; i < options.MaxIterations; i++)
 135            {
 177136                cancellationToken.ThrowIfCancellationRequested();
 137
 174138                context.Iteration = i;
 174139                currentIterationIndex = i;
 140
 141                // Hook: iteration start (wrapped to escape catch-all)
 174142                if (options.OnIterationStart != null)
 143                {
 2144                    await InvokeHookAsync(options.OnIterationStart, i, context).ConfigureAwait(false);
 145                }
 146
 147                // Build fresh prompt from workspace state
 173148                var budgetPressureTriggered = false;
 149                string userPrompt;
 150                try
 151                {
 173152                    userPrompt = options.PromptFactory(context);
 173153                }
 0154                catch (Exception ex)
 155                {
 0156                    succeeded = false;
 0157                    termination = TerminationReason.Error;
 0158                    errorMessage = $"Prompt factory failed on iteration {i}: {ex.Message}";
 0159                    diagnosticsBuilder.RecordFailure(errorMessage);
 0160                    break;
 161                }
 162
 163                // Budget pressure: if token usage is at or above the threshold,
 164                // prepend the finalization instruction and mark this as the last iteration.
 173165                if (options.BudgetPressureThreshold is { } threshold
 173166                    && _budgetTracker is { MaxTokens: > 0 } tracker)
 167                {
 2168                    var usage = (double)tracker.CurrentTokens / tracker.MaxTokens.Value;
 2169                    if (usage >= threshold)
 170                    {
 0171                        userPrompt = options.BudgetPressureInstruction + "\n\n" + userPrompt;
 0172                        budgetPressureTriggered = true;
 173                    }
 174                }
 175
 173176                var iterationStopwatch = Stopwatch.StartNew();
 173177                currentIterationStopwatch = iterationStopwatch;
 173178                var iterationToolCalls = new List<ToolCallResult>();
 173179                currentIterationToolCalls = iterationToolCalls;
 173180                ChatResponse? iterationResponse = null;
 173181                long iterationInputTokens = 0;
 173182                long iterationOutputTokens = 0;
 173183                long iterationTotalTokens = 0;
 173184                int llmCallCount = 0;
 185
 186                // Build messages — always just [system, user], no history
 173187                var messages = new List<ChatMessage>
 173188                {
 173189                    new(ChatRole.System, options.Instructions),
 173190                    new(ChatRole.User, userPrompt),
 173191                };
 192
 173193                var effectiveTools = options.ToolFilter is { } filter
 173194                    ? filter(i, context, options.Tools)
 173195                    : options.Tools;
 196
 173197                var chatOptions = new ChatOptions
 173198                {
 173199                    Tools = effectiveTools.Cast<AITool>().ToList(),
 173200                };
 201
 202                // Execute rounds within this iteration based on ToolResultMode
 173203                var maxRounds = options.ToolResultMode switch
 173204                {
 49205                    ToolResultMode.SingleCall => 1,
 108206                    ToolResultMode.OneRoundTrip => 2,
 16207                    ToolResultMode.MultiRound => options.MaxToolRoundsPerIteration,
 0208                    _ => 1,
 173209                };
 210
 566211                for (int round = 0; round < maxRounds; round++)
 212                {
 249213                    cancellationToken.ThrowIfCancellationRequested();
 214
 215                    // Check budget pressure between rounds (not just per iteration)
 249216                    if (round > 0
 249217                        && !budgetPressureTriggered
 249218                        && options.BudgetPressureThreshold is { } roundThreshold
 249219                        && _budgetTracker is { MaxTokens: > 0 } roundTracker)
 220                    {
 2221                        var roundUsage = (double)roundTracker.CurrentTokens / roundTracker.MaxTokens.Value;
 2222                        if (roundUsage >= roundThreshold)
 223                        {
 1224                            budgetPressureTriggered = true;
 1225                            break;
 226                        }
 227                    }
 228
 229                    ChatResponse response;
 230
 231                    try
 232                    {
 248233                        response = await chatClient.GetResponseAsync(
 248234                            messages, chatOptions, cancellationToken).ConfigureAwait(false);
 240235                    }
 5236                    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 237                    {
 1238                        throw; // genuine cancellation — let outer handler terminate the loop
 239                    }
 7240                    catch (Exception)
 241                    {
 242                        // Chat completion diagnostics are recorded by the middleware
 243                        // wrapping the chat client — the loop does not record them.
 7244                        diagnosticsBuilder.RecordInputMessageCount(messages.Count);
 7245                        throw;
 246                    }
 247
 240248                    llmCallCount++;
 249
 250                    // Track tokens
 720251                    long callInput = 0, callOutput = 0, callTotal = 0;
 240252                    if (response.Usage is { } usage)
 253                    {
 92254                        callInput = usage.InputTokenCount ?? 0;
 92255                        callOutput = usage.OutputTokenCount ?? 0;
 92256                        callTotal = usage.TotalTokenCount ?? 0;
 92257                        iterationInputTokens += callInput;
 92258                        iterationOutputTokens += callOutput;
 92259                        iterationTotalTokens += callTotal;
 260                    }
 261
 240262                    var responseMessageCount = response.Messages.Count;
 263
 264                    // Chat completion diagnostics are recorded by the middleware
 265                    // wrapping the chat client — the loop does not record them.
 240266                    diagnosticsBuilder.RecordInputMessageCount(messages.Count);
 240267                    diagnosticsBuilder.RecordOutputMessageCount(responseMessageCount);
 268
 269                    // Check for tool calls in response
 240270                    var functionCalls = response.Messages
 240271                        .SelectMany(m => m.Contents.OfType<FunctionCallContent>())
 240272                        .ToList();
 273
 240274                    if (functionCalls.Count == 0)
 275                    {
 276                        // Model produced text — natural termination for this iteration.
 277                        // Capture the full ChatResponse to preserve messages, usage, and
 278                        // any other metadata for downstream consumers and evaluation.
 88279                        iterationResponse = response;
 88280                        break;
 281                    }
 282
 283                    // Execute tool calls — limit to remaining allowance if MaxTotalToolCalls is set
 152284                    var remainingAllowance = options.MaxTotalToolCalls.HasValue
 152285                        ? options.MaxTotalToolCalls.Value - totalToolCalls
 152286                        : (int?)null;
 287
 152288                    var callsToExecute = remainingAllowance.HasValue && remainingAllowance.Value < functionCalls.Count
 152289                        ? functionCalls.Take(remainingAllowance.Value).ToList()
 152290                        : functionCalls;
 291
 292                    // Build per-call early exit check for AfterEachToolCall mode
 152293                    Func<List<ToolCallResult>, bool>? perCallEarlyExitCheck = null;
 152294                    if (options.CheckCompletionAfterToolCalls == ToolCompletionCheckMode.AfterEachToolCall
 152295                        && options.IsComplete is { } perCallIsComplete)
 296                    {
 5297                        perCallEarlyExitCheck = partialResults =>
 5298                        {
 9299                            context.LastToolResults = partialResults;
 9300                            return perCallIsComplete(context);
 5301                        };
 302                    }
 303
 152304                    var (roundResults, earlyExitFromToolCall) = await ExecuteToolCallsAsync(
 152305                        callsToExecute, options.Tools, diagnosticsBuilder,
 152306                        i, options.OnToolCall, _progressReporterAccessor,
 152307                        _metrics, perCallEarlyExitCheck, cancellationToken)
 152308                        .ConfigureAwait(false);
 152309                    iterationToolCalls.AddRange(roundResults);
 152310                    totalToolCalls += roundResults.Count;
 311
 312                    // Early completion check (fires before MaxTotalToolCalls so completion wins)
 152313                    if (earlyExitFromToolCall)
 314                    {
 4315                        termination = TerminationReason.CompletedEarlyAfterToolCall;
 4316                        break;
 317                    }
 318
 148319                    if (options.CheckCompletionAfterToolCalls == ToolCompletionCheckMode.AfterToolRounds
 148320                        || options.CheckCompletionAfterToolCalls == ToolCompletionCheckMode.AfterEachToolCall)
 321                    {
 7322                        if (options.IsComplete is { } earlyCheck)
 323                        {
 7324                            context.LastToolResults = iterationToolCalls;
 7325                            if (earlyCheck(context))
 326                            {
 6327                                termination = TerminationReason.CompletedEarlyAfterToolCall;
 6328                                break;
 329                            }
 330                        }
 331                    }
 332
 333                    // Check MaxTotalToolCalls guard
 142334                    if (options.MaxTotalToolCalls is { } maxCalls && totalToolCalls >= maxCalls)
 335                    {
 1336                        termination = TerminationReason.MaxToolCallsReached;
 1337                        succeeded = false;
 1338                        errorMessage = $"Cumulative tool call count ({totalToolCalls}) reached MaxTotalToolCalls ({maxCa
 1339                        diagnosticsBuilder.RecordFailure(errorMessage);
 1340                        break;
 341                    }
 342
 343                    // For SingleCall mode, don't send results back — just store them
 141344                    if (options.ToolResultMode == ToolResultMode.SingleCall)
 345                    {
 346                        break;
 347                    }
 348
 349                    // For OneRoundTrip/MultiRound, send results back to model
 350                    // Add assistant message with tool calls
 110351                    var assistantMessage = new ChatMessage(ChatRole.Assistant,
 223352                        functionCalls.Select(fc => (AIContent)fc).ToList());
 110353                    messages.Add(assistantMessage);
 354
 355                    // Add tool result messages
 446356                    foreach (var (fc, result) in functionCalls.Zip(roundResults))
 357                    {
 113358                        var resultContent = result.Succeeded
 113359                            ? ToolResultSerializer.Serialize(result.Result)
 113360                            : $"Error: {result.ErrorMessage}";
 361
 113362                        messages.Add(new ChatMessage(ChatRole.Tool,
 113363                            [new FunctionResultContent(fc.CallId, resultContent)]));
 364                    }
 365
 366                    // For OneRoundTrip, if this was the first round (round 0),
 367                    // we'll do ONE more LLM call. If it's round 1, we're done.
 368                    // For MultiRound, we continue until maxRounds or text response.
 110369                }
 370
 371                // If a guard triggered termination inside the round loop, break outer loop too
 165372                if (termination == TerminationReason.MaxToolCallsReached)
 373                {
 374                    // Still record the partial iteration
 1375                    iterationStopwatch.Stop();
 1376                    iterations.Add(new IterationRecord(
 1377                        Iteration: i,
 1378                        ToolCalls: iterationToolCalls,
 1379                        FinalResponse: iterationResponse,
 1380                        Tokens: new TokenUsage(iterationInputTokens, iterationOutputTokens, iterationTotalTokens, 0, 0),
 1381                        Duration: iterationStopwatch.Elapsed,
 1382                        LlmCallCount: llmCallCount,
 1383                        ToolCallCount: iterationToolCalls.Count));
 1384                    context.LastToolResults = iterationToolCalls;
 1385                    break;
 386                }
 387
 388                // Early completion after tool call — record iteration, fire hooks, then exit
 164389                if (termination == TerminationReason.CompletedEarlyAfterToolCall)
 390                {
 10391                    iterationStopwatch.Stop();
 10392                    iterations.Add(new IterationRecord(
 10393                        Iteration: i,
 10394                        ToolCalls: iterationToolCalls,
 10395                        FinalResponse: iterationResponse,
 10396                        Tokens: new TokenUsage(iterationInputTokens, iterationOutputTokens, iterationTotalTokens, 0, 0),
 10397                        Duration: iterationStopwatch.Elapsed,
 10398                        LlmCallCount: llmCallCount,
 10399                        ToolCallCount: iterationToolCalls.Count));
 10400                    context.LastToolResults = iterationToolCalls;
 401
 10402                    if (options.OnIterationEnd != null)
 403                    {
 1404                        await InvokeHookAsync(options.OnIterationEnd, iterations[^1]).ConfigureAwait(false);
 405                    }
 406
 1407                    break;
 408                }
 409
 154410                iterationStopwatch.Stop();
 411
 154412                var tokenUsage = new TokenUsage(
 154413                    InputTokens: iterationInputTokens,
 154414                    OutputTokens: iterationOutputTokens,
 154415                    TotalTokens: iterationTotalTokens,
 154416                    CachedInputTokens: 0,
 154417                    ReasoningTokens: 0);
 418
 154419                iterations.Add(new IterationRecord(
 154420                    Iteration: i,
 154421                    ToolCalls: iterationToolCalls,
 154422                    FinalResponse: iterationResponse,
 154423                    Tokens: tokenUsage,
 154424                    Duration: iterationStopwatch.Elapsed,
 154425                    LlmCallCount: llmCallCount,
 154426                    ToolCallCount: iterationToolCalls.Count));
 427
 428                // Update context for next iteration
 154429                context.LastToolResults = iterationToolCalls;
 430
 431                // Hook: iteration end (wrapped to escape catch-all)
 154432                if (options.OnIterationEnd != null)
 433                {
 4434                    await InvokeHookAsync(options.OnIterationEnd, iterations[^1]).ConfigureAwait(false);
 435                }
 436
 437                // Stall detection — compare consecutive iterations
 154438                if (options.StallDetection is { } stallOpts && iterations.Count >= 2)
 439                {
 13440                    var currentTokens = iterations[^1].Tokens.TotalTokens;
 13441                    var consecutiveSimilar = 0;
 442
 38443                    for (int s = iterations.Count - 2; s >= 0; s--)
 444                    {
 15445                        var prevTokens = iterations[s].Tokens.TotalTokens;
 15446                        if (prevTokens > 0)
 447                        {
 15448                            var delta = Math.Abs(currentTokens - prevTokens) / (double)prevTokens;
 15449                            if (delta <= stallOpts.TolerancePercent)
 450                            {
 6451                                consecutiveSimilar++;
 6452                                currentTokens = prevTokens;
 453                            }
 454                            else
 455                            {
 456                                break;
 457                            }
 458                        }
 459                        else
 460                        {
 461                            break;
 462                        }
 463                    }
 464
 13465                    if (consecutiveSimilar >= stallOpts.ConsecutiveThreshold - 1)
 466                    {
 2467                        termination = TerminationReason.StallDetected;
 2468                        succeeded = false;
 2469                        errorMessage = $"Stall detected: {consecutiveSimilar + 1} consecutive iterations " +
 2470                            $"with similar token counts (~{iterations[^1].Tokens.TotalTokens} tokens, " +
 2471                            $"tolerance {stallOpts.TolerancePercent:P0}).";
 2472                        diagnosticsBuilder.RecordFailure(errorMessage);
 2473                        break;
 474                    }
 475                }
 476
 477                // Check IsComplete predicate
 152478                if (options.IsComplete?.Invoke(context) == true)
 479                {
 24480                    termination = TerminationReason.Completed;
 24481                    break;
 482                }
 483
 484                // Budget pressure: this was the finalization iteration — stop now
 128485                if (budgetPressureTriggered)
 486                {
 1487                    termination = TerminationReason.BudgetPressure;
 1488                    break;
 489                }
 490
 491                // If model produced text (no tool calls), the loop is done
 127492                if (iterationResponse != null)
 493                {
 67494                    finalResponse = iterationResponse;
 67495                    termination = TerminationReason.NaturalCompletion;
 67496                    break;
 497                }
 60498            }
 499
 500            // If the loop exhausted MaxIterations without IsComplete returning true
 501            // and without a text response, that's a failure — the agent didn't finish.
 113502            if (termination == TerminationReason.Completed
 113503                && finalResponse == null
 113504                && options.IsComplete?.Invoke(context) != true
 113505                && iterations.Count >= options.MaxIterations)
 506            {
 8507                succeeded = false;
 8508                termination = TerminationReason.MaxIterationsReached;
 8509                errorMessage = $"Loop exhausted {options.MaxIterations} iterations without completing. "
 8510                    + "The IsComplete predicate never returned true and the model never produced a text response.";
 8511                diagnosticsBuilder.RecordFailure(errorMessage);
 512            }
 113513        }
 8514        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 515        {
 4516            succeeded = false;
 4517            termination = TerminationReason.Cancelled;
 4518            errorMessage = $"Loop was cancelled after {iterations.Count} completed iteration(s).";
 4519            diagnosticsBuilder.RecordFailure(errorMessage);
 4520            RecordPartialIteration(iterations, currentIterationIndex, currentIterationToolCalls, currentIterationStopwat
 4521        }
 4522        catch (OperationCanceledException ex)
 523        {
 524            // HTTP timeout (TaskCanceledException with TimeoutException inner)
 525            // or other non-user cancellation — report as Error, not Cancelled.
 4526            succeeded = false;
 4527            termination = TerminationReason.Error;
 4528            errorMessage = ex.InnerException is TimeoutException
 4529                ? $"Chat completion timed out on iteration {iterations.Count + 1}: {ex.InnerException.Message}"
 4530                : $"Operation cancelled (not by caller) on iteration {iterations.Count + 1}: {ex.Message}";
 4531            diagnosticsBuilder.RecordFailure(errorMessage);
 4532            RecordPartialIteration(iterations, currentIterationIndex, currentIterationToolCalls, currentIterationStopwat
 4533        }
 534        catch (LifecycleHookException hookEx)
 535        {
 536            // Lifecycle hook exceptions propagate to the caller — they are
 537            // user-controlled code and should not be silently swallowed.
 1538            throw hookEx.InnerException!;
 539        }
 3540        catch (Exception ex)
 541        {
 3542            succeeded = false;
 3543            termination = TerminationReason.Error;
 3544            errorMessage = ex.Message;
 3545            diagnosticsBuilder.RecordFailure(errorMessage);
 3546            RecordPartialIteration(iterations, currentIterationIndex, currentIterationToolCalls, currentIterationStopwat
 3547        }
 548
 124549        if (finalResponse == null && iterations.Count > 0)
 550        {
 551            // Get final response from last iteration if available
 55552            finalResponse = iterations[^1].FinalResponse;
 553        }
 554
 124555        var diagnostics = diagnosticsBuilder.Build();
 124556        diagnosticsBuilder.Dispose();
 124557        _diagnosticsWriter?.Set(diagnostics);
 124558        _metrics?.RecordRunCompleted(diagnostics);
 124559        executionContextScope?.Dispose();
 560
 124561        var configuration = new IterativeLoopConfiguration(
 124562            ToolResultMode: options.ToolResultMode,
 124563            MaxIterations: options.MaxIterations,
 124564            MaxToolRoundsPerIteration: options.MaxToolRoundsPerIteration,
 124565            MaxTotalToolCalls: options.MaxTotalToolCalls,
 124566            BudgetPressureThreshold: options.BudgetPressureThreshold,
 124567            LoopName: options.LoopName,
 124568            CheckCompletionAfterToolCalls: options.CheckCompletionAfterToolCalls);
 569
 124570        return new IterativeLoopResult(
 124571            Iterations: iterations,
 124572            FinalResponse: finalResponse,
 124573            Diagnostics: diagnostics,
 124574            Succeeded: succeeded,
 124575            ErrorMessage: errorMessage,
 124576            Termination: termination,
 124577            Configuration: configuration);
 124578    }
 579
 580    /// <summary>
 581    /// Records a partial <see cref="IterationRecord"/> for an iteration that was
 582    /// interrupted by an exception. Captures whatever tool calls and timing data
 583    /// were accumulated before the interruption.
 584    /// </summary>
 585    private static void RecordPartialIteration(
 586        List<IterationRecord> iterations,
 587        int currentIterationIndex,
 588        List<ToolCallResult>? toolCalls,
 589        Stopwatch? stopwatch)
 590    {
 11591        if (currentIterationIndex < 0 || currentIterationIndex < iterations.Count)
 592        {
 3593            return;
 594        }
 595
 8596        stopwatch?.Stop();
 8597        iterations.Add(new IterationRecord(
 8598            Iteration: currentIterationIndex,
 8599            ToolCalls: toolCalls ?? [],
 8600            FinalResponse: null,
 8601            Tokens: new TokenUsage(0, 0, 0, 0, 0),
 8602            Duration: stopwatch?.Elapsed ?? TimeSpan.Zero,
 8603            LlmCallCount: 0,
 8604            ToolCallCount: toolCalls?.Count ?? 0));
 8605    }
 606
 607    private static async Task<(List<ToolCallResult> Results, bool EarlyExit)> ExecuteToolCallsAsync(
 608        List<FunctionCallContent> functionCalls,
 609        IReadOnlyList<AITool> tools,
 610        AgentRunDiagnosticsBuilder diagnosticsBuilder,
 611        int iteration,
 612        Func<int, ToolCallResult, Task>? onToolCall,
 613        IProgressReporterAccessor? progressAccessor,
 614        IAgentMetrics? metrics,
 615        Func<List<ToolCallResult>, bool>? earlyExitCheck,
 616        CancellationToken cancellationToken)
 617    {
 152618        var toolMap = tools.OfType<AIFunction>()
 316619            .ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase);
 620
 152621        var results = new List<ToolCallResult>();
 152622        var reporter = progressAccessor?.Current;
 623
 616624        foreach (var fc in functionCalls)
 625        {
 158626            var sequence = diagnosticsBuilder.NextToolCallSequence();
 158627            var startedAt = DateTimeOffset.UtcNow;
 158628            var stopwatch = Stopwatch.StartNew();
 629
 158630            using var activity = metrics?.ActivitySource.StartActivity($"agent.tool {fc.Name}", ActivityKind.Internal);
 158631            activity?.SetTag("agent.tool.name", fc.Name);
 158632            activity?.SetTag("agent.tool.sequence", sequence);
 158633            activity?.SetTag("gen_ai.agent.name", diagnosticsBuilder.AgentName);
 634
 158635            reporter?.Report(new ToolCallStartedEvent(
 158636                Timestamp: startedAt,
 158637                WorkflowId: reporter.WorkflowId,
 158638                AgentId: reporter.AgentId,
 158639                ParentAgentId: null,
 158640                Depth: reporter.Depth,
 158641                SequenceNumber: reporter.NextSequence(),
 158642                ToolName: fc.Name));
 643
 158644            if (!toolMap.TryGetValue(fc.Name, out var function))
 645            {
 2646                stopwatch.Stop();
 2647                var errorResult = new ToolCallResult(
 2648                    FunctionName: fc.Name,
 2649                    Arguments: ToReadOnly(fc.Arguments),
 2650                    Result: null,
 2651                    Duration: stopwatch.Elapsed,
 2652                    Succeeded: false,
 2653                    ErrorMessage: $"Unknown tool: '{fc.Name}'");
 654
 2655                diagnosticsBuilder.AddToolCall(new ToolCallDiagnostics(
 2656                    Sequence: sequence,
 2657                    ToolName: fc.Name,
 2658                    Duration: stopwatch.Elapsed,
 2659                    Succeeded: false,
 2660                    ErrorMessage: errorResult.ErrorMessage,
 2661                    StartedAt: startedAt,
 2662                    CompletedAt: DateTimeOffset.UtcNow,
 2663                    CustomMetrics: null)
 2664                {
 2665                    AgentName = diagnosticsBuilder.AgentName,
 2666                    Arguments = ToReadOnly(fc.Arguments),
 2667                    ArgumentsCharCount = DiagnosticsCharCounter.JsonLength(fc.Arguments),
 2668                });
 2669                metrics?.RecordToolCall(fc.Name, stopwatch.Elapsed, succeeded: false, agentName: diagnosticsBuilder.Agen
 2670                activity?.SetStatus(ActivityStatusCode.Error, errorResult.ErrorMessage);
 2671                activity?.SetTag("status", "failed");
 672
 2673                reporter?.Report(new ToolCallFailedEvent(
 2674                    Timestamp: DateTimeOffset.UtcNow,
 2675                    WorkflowId: reporter.WorkflowId,
 2676                    AgentId: reporter.AgentId,
 2677                    ParentAgentId: null,
 2678                    Depth: reporter.Depth,
 2679                    SequenceNumber: reporter.NextSequence(),
 2680                    ToolName: fc.Name,
 2681                    ErrorMessage: errorResult.ErrorMessage ?? "Unknown tool",
 2682                    Duration: stopwatch.Elapsed));
 683
 2684                results.Add(errorResult);
 685
 2686                if (onToolCall != null)
 687                {
 0688                    await InvokeHookAsync(onToolCall, iteration, errorResult).ConfigureAwait(false);
 689                }
 690
 2691                if (earlyExitCheck != null && earlyExitCheck(results))
 692                {
 0693                    return (results, EarlyExit: true);
 694                }
 695
 2696                continue;
 697            }
 698
 699            try
 700            {
 156701                var result = await function.InvokeAsync(
 156702                    fc.Arguments is { } args ? new AIFunctionArguments(args) : null,
 156703                    cancellationToken).ConfigureAwait(false);
 704
 153705                stopwatch.Stop();
 706
 153707                diagnosticsBuilder.AddToolCall(new ToolCallDiagnostics(
 153708                    Sequence: sequence,
 153709                    ToolName: fc.Name,
 153710                    Duration: stopwatch.Elapsed,
 153711                    Succeeded: true,
 153712                    ErrorMessage: null,
 153713                    StartedAt: startedAt,
 153714                    CompletedAt: DateTimeOffset.UtcNow,
 153715                    CustomMetrics: null)
 153716                {
 153717                    AgentName = diagnosticsBuilder.AgentName,
 153718                    Arguments = ToReadOnly(fc.Arguments),
 153719                    Result = result,
 153720                    ArgumentsCharCount = DiagnosticsCharCounter.JsonLength(fc.Arguments),
 153721                    ResultCharCount = DiagnosticsCharCounter.JsonLength(result),
 153722                });
 153723                metrics?.RecordToolCall(fc.Name, stopwatch.Elapsed, succeeded: true, agentName: diagnosticsBuilder.Agent
 153724                activity?.SetTag("status", "success");
 725
 153726                reporter?.Report(new ToolCallCompletedEvent(
 153727                    Timestamp: DateTimeOffset.UtcNow,
 153728                    WorkflowId: reporter.WorkflowId,
 153729                    AgentId: reporter.AgentId,
 153730                    ParentAgentId: null,
 153731                    Depth: reporter.Depth,
 153732                    SequenceNumber: reporter.NextSequence(),
 153733                    ToolName: fc.Name,
 153734                    Duration: stopwatch.Elapsed,
 153735                    CustomMetrics: null));
 736
 153737                results.Add(new ToolCallResult(
 153738                    FunctionName: fc.Name,
 153739                    Arguments: ToReadOnly(fc.Arguments),
 153740                    Result: result,
 153741                    Duration: stopwatch.Elapsed,
 153742                    Succeeded: true,
 153743                    ErrorMessage: null));
 744
 153745                if (onToolCall != null)
 746                {
 1747                    await InvokeHookAsync(onToolCall, iteration, results[^1]).ConfigureAwait(false);
 748                }
 153749            }
 3750            catch (Exception ex)
 751            {
 3752                stopwatch.Stop();
 753
 3754                diagnosticsBuilder.AddToolCall(new ToolCallDiagnostics(
 3755                    Sequence: sequence,
 3756                    ToolName: fc.Name,
 3757                    Duration: stopwatch.Elapsed,
 3758                    Succeeded: false,
 3759                    ErrorMessage: ex.Message,
 3760                    StartedAt: startedAt,
 3761                    CompletedAt: DateTimeOffset.UtcNow,
 3762                    CustomMetrics: null)
 3763                {
 3764                    AgentName = diagnosticsBuilder.AgentName,
 3765                    Arguments = ToReadOnly(fc.Arguments),
 3766                    ArgumentsCharCount = DiagnosticsCharCounter.JsonLength(fc.Arguments),
 3767                });
 3768                metrics?.RecordToolCall(fc.Name, stopwatch.Elapsed, succeeded: false, agentName: diagnosticsBuilder.Agen
 3769                activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
 3770                activity?.SetTag("status", "failed");
 771
 3772                reporter?.Report(new ToolCallFailedEvent(
 3773                    Timestamp: DateTimeOffset.UtcNow,
 3774                    WorkflowId: reporter.WorkflowId,
 3775                    AgentId: reporter.AgentId,
 3776                    ParentAgentId: null,
 3777                    Depth: reporter.Depth,
 3778                    SequenceNumber: reporter.NextSequence(),
 3779                    ToolName: fc.Name,
 3780                    ErrorMessage: ex.Message,
 3781                    Duration: stopwatch.Elapsed));
 782
 3783                results.Add(new ToolCallResult(
 3784                    FunctionName: fc.Name,
 3785                    Arguments: ToReadOnly(fc.Arguments),
 3786                    Result: null,
 3787                    Duration: stopwatch.Elapsed,
 3788                    Succeeded: false,
 3789                    ErrorMessage: ex.Message));
 790
 3791                if (onToolCall != null)
 792                {
 0793                    await InvokeHookAsync(onToolCall, iteration, results[^1]).ConfigureAwait(false);
 794                }
 795            }
 796
 797            // Per-call early exit check
 156798            if (earlyExitCheck != null && earlyExitCheck(results))
 799            {
 4800                return (results, EarlyExit: true);
 801            }
 152802        }
 803
 148804        return (results, EarlyExit: false);
 152805    }
 806
 807    private static async Task InvokeHookAsync<T>(Func<T, Task> hook, T arg)
 808    {
 809        try
 810        {
 5811            await hook(arg).ConfigureAwait(false);
 5812        }
 813        catch (Exception ex)
 814        {
 0815            throw new LifecycleHookException(ex);
 816        }
 5817    }
 818
 819    private static async Task InvokeHookAsync<T1, T2>(Func<T1, T2, Task> hook, T1 arg1, T2 arg2)
 820    {
 821        try
 822        {
 3823            await hook(arg1, arg2).ConfigureAwait(false);
 2824        }
 825        catch (Exception ex)
 826        {
 1827            throw new LifecycleHookException(ex);
 828        }
 2829    }
 830
 831    private static IReadOnlyDictionary<string, object?> ToReadOnly(
 832        IDictionary<string, object?>? arguments) =>
 316833        arguments is IReadOnlyDictionary<string, object?> ro
 316834            ? ro
 316835            : arguments is not null
 316836                ? new Dictionary<string, object?>(arguments)
 316837                : new Dictionary<string, object?>();
 838
 839
 840}