< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Diagnostics.DiagnosticsFunctionInvokingChatClient
Assembly: NexusLabs.Needlr.AgentFramework
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/Diagnostics/DiagnosticsFunctionInvokingChatClient.cs
Line coverage
97%
Covered lines: 100
Uncovered lines: 3
Coverable lines: 103
Total lines: 194
Line coverage: 97%
Branch coverage
55%
Covered branches: 38
Total branches: 68
Branch coverage: 55.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%
InvokeFunctionAsync()56.25%646497.91%
SnapshotArguments(...)50%5466.66%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework/Diagnostics/DiagnosticsFunctionInvokingChatClient.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Diagnostics;
 3
 4using Microsoft.Extensions.AI;
 5
 6using NexusLabs.Needlr.AgentFramework.Progress;
 7
 8namespace NexusLabs.Needlr.AgentFramework.Diagnostics;
 9
 10/// <summary>
 11/// A <see cref="FunctionInvokingChatClient"/> that records per-tool-call diagnostics,
 12/// OTel metrics, and Activity spans for every function invocation. This is the MEAI-native
 13/// equivalent of the MAF <c>DiagnosticsFunctionCallingMiddleware</c> in Workflows.
 14/// </summary>
 15/// <remarks>
 16/// <para>
 17/// Use this when the chat pipeline includes <c>FunctionInvokingChatClient</c> (auto
 18/// tool calling) rather than the <c>IterativeAgentLoop</c>. The loop does its own
 19/// tool-call recording; using both would produce duplicates.
 20/// </para>
 21/// <para>
 22/// Records are written to the AsyncLocal <see cref="AgentRunDiagnosticsBuilder"/> and
 23/// OTel metrics via <see cref="IAgentMetrics"/>. Progress events are emitted to
 24/// <see cref="IProgressReporterAccessor"/> when available.
 25/// </para>
 26/// </remarks>
 27[DoNotAutoRegister]
 28public sealed class DiagnosticsFunctionInvokingChatClient : FunctionInvokingChatClient
 29{
 30    private readonly IAgentMetrics? _metrics;
 31    private readonly IProgressReporterAccessor? _progressAccessor;
 32
 33    /// <summary>
 34    /// Creates a new diagnostics-enabled <see cref="FunctionInvokingChatClient"/>.
 35    /// </summary>
 36    /// <param name="innerClient">The inner chat client to delegate to.</param>
 37    /// <param name="metrics">Optional OTel metrics recorder.</param>
 38    /// <param name="progressAccessor">Optional progress reporter for real-time events.</param>
 39    public DiagnosticsFunctionInvokingChatClient(
 40        IChatClient innerClient,
 41        IAgentMetrics? metrics = null,
 42        IProgressReporterAccessor? progressAccessor = null)
 643        : base(innerClient)
 44    {
 645        _metrics = metrics;
 646        _progressAccessor = progressAccessor;
 647    }
 48
 49    /// <inheritdoc />
 50    protected override async ValueTask<object?> InvokeFunctionAsync(
 51        FunctionInvocationContext context,
 52        CancellationToken cancellationToken)
 53    {
 654        var diagnosticsBuilder = AgentRunDiagnosticsBuilder.GetCurrent();
 655        var sequence = diagnosticsBuilder?.NextToolCallSequence() ?? -1;
 656        var startedAt = DateTimeOffset.UtcNow;
 657        var stopwatch = Stopwatch.StartNew();
 58
 659        var toolName = context.Function?.Name ?? "unknown";
 60
 661        using var activity = _metrics?.ActivitySource.StartActivity(
 662            $"agent.tool {toolName}", ActivityKind.Internal);
 663        activity?.SetTag("agent.tool.name", toolName);
 664        activity?.SetTag("agent.tool.sequence", sequence);
 665        activity?.SetTag("gen_ai.agent.name", diagnosticsBuilder?.AgentName);
 66
 667        var reporter = _progressAccessor?.Current;
 668        reporter?.Report(new ToolCallStartedEvent(
 669            Timestamp: startedAt,
 670            WorkflowId: reporter.WorkflowId,
 671            AgentId: reporter.AgentId,
 672            ParentAgentId: diagnosticsBuilder?.ParentAgentName,
 673            Depth: reporter.Depth,
 674            SequenceNumber: reporter.NextSequence(),
 675            ToolName: toolName));
 76
 677        var customMetrics = new ConcurrentDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 678        ToolMetricsAccessor.CurrentToolMetrics.Value = customMetrics;
 79
 80        try
 81        {
 682            var result = await base.InvokeFunctionAsync(context, cancellationToken)
 683                .ConfigureAwait(false);
 84
 585            stopwatch.Stop();
 86
 587            activity?.SetTag("status", "success");
 88
 589            if (activity is not null && customMetrics.Count > 0)
 90            {
 091                foreach (var (key, value) in customMetrics)
 92                {
 093                    activity.SetTag($"tool.custom.{key}", value);
 94                }
 95            }
 96
 597            _metrics?.RecordToolCall(
 598                toolName, stopwatch.Elapsed, succeeded: true,
 599                agentName: diagnosticsBuilder?.AgentName);
 100
 5101            var arguments = SnapshotArguments(context.Arguments);
 102
 5103            var toolDiag = new ToolCallDiagnostics(
 5104                Sequence: sequence,
 5105                ToolName: toolName,
 5106                Duration: stopwatch.Elapsed,
 5107                Succeeded: true,
 5108                ErrorMessage: null,
 5109                StartedAt: startedAt,
 5110                CompletedAt: DateTimeOffset.UtcNow,
 5111                CustomMetrics: customMetrics.Count > 0 ? customMetrics : null)
 5112            {
 5113                AgentName = diagnosticsBuilder?.AgentName,
 5114                Arguments = arguments,
 5115                Result = result,
 5116                ArgumentsCharCount = DiagnosticsCharCounter.JsonLength(arguments),
 5117                ResultCharCount = DiagnosticsCharCounter.JsonLength(result),
 5118            };
 119
 5120            diagnosticsBuilder?.AddToolCall(toolDiag);
 121
 5122            reporter?.Report(new ToolCallCompletedEvent(
 5123                Timestamp: DateTimeOffset.UtcNow,
 5124                WorkflowId: reporter.WorkflowId,
 5125                AgentId: reporter.AgentId,
 5126                ParentAgentId: diagnosticsBuilder?.ParentAgentName,
 5127                Depth: reporter.Depth,
 5128                SequenceNumber: reporter.NextSequence(),
 5129                ToolName: toolName,
 5130                Duration: stopwatch.Elapsed,
 5131                CustomMetrics: customMetrics.Count > 0 ? customMetrics : null));
 132
 5133            return result;
 134        }
 1135        catch (Exception ex)
 136        {
 1137            stopwatch.Stop();
 138
 1139            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
 1140            activity?.SetTag("status", "failed");
 141
 1142            _metrics?.RecordToolCall(
 1143                toolName, stopwatch.Elapsed, succeeded: false,
 1144                agentName: diagnosticsBuilder?.AgentName);
 145
 1146            var arguments = SnapshotArguments(context.Arguments);
 147
 1148            var failedToolDiag = new ToolCallDiagnostics(
 1149                Sequence: sequence,
 1150                ToolName: toolName,
 1151                Duration: stopwatch.Elapsed,
 1152                Succeeded: false,
 1153                ErrorMessage: ex.Message,
 1154                StartedAt: startedAt,
 1155                CompletedAt: DateTimeOffset.UtcNow,
 1156                CustomMetrics: customMetrics.Count > 0 ? customMetrics : null)
 1157            {
 1158                AgentName = diagnosticsBuilder?.AgentName,
 1159                Arguments = arguments,
 1160                ArgumentsCharCount = DiagnosticsCharCounter.JsonLength(arguments),
 1161            };
 162
 1163            diagnosticsBuilder?.AddToolCall(failedToolDiag);
 164
 1165            reporter?.Report(new ToolCallFailedEvent(
 1166                Timestamp: DateTimeOffset.UtcNow,
 1167                WorkflowId: reporter.WorkflowId,
 1168                AgentId: reporter.AgentId,
 1169                ParentAgentId: diagnosticsBuilder?.ParentAgentName,
 1170                Depth: reporter.Depth,
 1171                SequenceNumber: reporter.NextSequence(),
 1172                ToolName: toolName,
 1173                ErrorMessage: ex.Message,
 1174                Duration: stopwatch.Elapsed));
 175
 1176            throw;
 177        }
 178        finally
 179        {
 6180            ToolMetricsAccessor.CurrentToolMetrics.Value = null;
 181        }
 5182    }
 183
 184    private static IReadOnlyDictionary<string, object?>? SnapshotArguments(
 185        IReadOnlyDictionary<string, object?>? arguments)
 186    {
 6187        if (arguments is null || arguments.Count == 0)
 188        {
 0189            return null;
 190        }
 191
 6192        return new Dictionary<string, object?>(arguments);
 193    }
 194}