< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Evaluation.ToolCallTrajectoryEvaluator
Assembly: NexusLabs.Needlr.AgentFramework.Evaluation
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Evaluation/ToolCallTrajectoryEvaluator.cs
Line coverage
99%
Covered lines: 130
Uncovered lines: 1
Coverable lines: 131
Total lines: 281
Line coverage: 99.2%
Branch coverage
98%
Covered branches: 51
Total branches: 52
Branch coverage: 98%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_EvaluationMetricNames()100%210%
.ctor()100%11100%
EvaluateAsync(...)96.15%2626100%
CountSequenceGaps(...)100%88100%
CountConsecutiveSameTool(...)100%66100%
BuildPerToolFailureRate(...)100%88100%
ComputeLatencyPercentiles(...)100%44100%
NearestRankPercentile(...)100%11100%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Evaluation/ToolCallTrajectoryEvaluator.cs

#LineLine coverage
 1using System.Text.Json;
 2
 3using Microsoft.Extensions.AI;
 4using Microsoft.Extensions.AI.Evaluation;
 5
 6using NexusLabs.Needlr.AgentFramework.Diagnostics;
 7
 8namespace NexusLabs.Needlr.AgentFramework.Evaluation;
 9
 10/// <summary>
 11/// Deterministic evaluator that scores the tool-call trajectory of an agent run from
 12/// the captured <see cref="IAgentRunDiagnostics"/> snapshot carried in an
 13/// <see cref="AgentRunDiagnosticsContext"/>.
 14/// </summary>
 15/// <remarks>
 16/// <para>
 17/// This evaluator never contacts a language model. It reads the ordered
 18/// <see cref="IAgentRunDiagnostics.ToolCalls"/> collection and produces:
 19/// </para>
 20/// <list type="bullet">
 21///   <item><description><c>Tool Calls Total</c> — total number of tool invocations.</description></item>
 22///   <item><description><c>Tool Calls Failed</c> — count of tool invocations whose <see cref="ToolCallDiagnostics.Succe
 23///   <item><description><c>Tool Call Sequence Gaps</c> — number of missing slots in the <see cref="ToolCallDiagnostics.
 24///   <item><description><c>All Tool Calls Succeeded</c> — boolean rollup. <see langword="true"/> when every tool invoca
 25///   <item><description><c>Consecutive Same-Tool Calls</c> — count of consecutive tool invocations with the same <see c
 26///   <item><description><c>Per-Tool Failure Rate</c> — JSON string mapping each tool name to its failure rate (0.0–1.0)
 27///   <item><description><c>Tool Call Latency P50</c> — 50th percentile of tool call durations in milliseconds (nearest-
 28///   <item><description><c>Tool Call Latency P95</c> — 95th percentile of tool call durations in milliseconds (nearest-
 29/// </list>
 30/// <para>
 31/// When no <see cref="AgentRunDiagnosticsContext"/> is present in the
 32/// <c>additionalContext</c> collection, the evaluator returns an empty
 33/// <see cref="EvaluationResult"/> — callers should treat that as "not applicable".
 34/// </para>
 35/// </remarks>
 36public sealed class ToolCallTrajectoryEvaluator : IEvaluator
 37{
 38    /// <summary>Metric name for the total tool-call count.</summary>
 39    public const string TotalMetricName = "Tool Calls Total";
 40
 41    /// <summary>Metric name for the failed tool-call count.</summary>
 42    public const string FailedMetricName = "Tool Calls Failed";
 43
 44    /// <summary>Metric name for the number of gaps in the recorded tool-call sequence.</summary>
 45    public const string SequenceGapsMetricName = "Tool Call Sequence Gaps";
 46
 47    /// <summary>Metric name for the boolean rollup indicating every tool call succeeded.</summary>
 48    public const string AllSucceededMetricName = "All Tool Calls Succeeded";
 49
 50    /// <summary>Metric name for the count of consecutive tool calls with the same tool name.</summary>
 51    public const string ConsecutiveSameToolMetricName = "Consecutive Same-Tool Calls";
 52
 53    /// <summary>Metric name for the JSON-formatted per-tool failure rate breakdown.</summary>
 54    public const string PerToolFailureRateMetricName = "Per-Tool Failure Rate";
 55
 56    /// <summary>Metric name for the 50th percentile tool-call latency in milliseconds.</summary>
 57    public const string LatencyP50MetricName = "Tool Call Latency P50";
 58
 59    /// <summary>Metric name for the 95th percentile tool-call latency in milliseconds.</summary>
 60    public const string LatencyP95MetricName = "Tool Call Latency P95";
 61
 62    /// <inheritdoc />
 063    public IReadOnlyCollection<string> EvaluationMetricNames { get; } =
 1664    [
 1665        TotalMetricName,
 1666        FailedMetricName,
 1667        SequenceGapsMetricName,
 1668        AllSucceededMetricName,
 1669        ConsecutiveSameToolMetricName,
 1670        PerToolFailureRateMetricName,
 1671        LatencyP50MetricName,
 1672        LatencyP95MetricName,
 1673    ];
 74
 75    /// <inheritdoc />
 76    public ValueTask<EvaluationResult> EvaluateAsync(
 77        IEnumerable<ChatMessage> messages,
 78        ChatResponse modelResponse,
 79        ChatConfiguration? chatConfiguration = null,
 80        IEnumerable<EvaluationContext>? additionalContext = null,
 81        CancellationToken cancellationToken = default)
 82    {
 1683        var diagnostics = additionalContext?
 1684            .OfType<AgentRunDiagnosticsContext>()
 1685            .FirstOrDefault()?
 1686            .Diagnostics;
 87
 1688        if (diagnostics is null)
 89        {
 190            return new ValueTask<EvaluationResult>(new EvaluationResult());
 91        }
 92
 1593        var toolCalls = diagnostics.ToolCalls;
 1594        var total = toolCalls.Count;
 1595        var failed = 0;
 9696        for (var i = 0; i < toolCalls.Count; i++)
 97        {
 3398            if (!toolCalls[i].Succeeded)
 99            {
 3100                failed++;
 101            }
 102        }
 103
 15104        var gaps = CountSequenceGaps(toolCalls);
 15105        var allSucceeded = failed == 0;
 15106        var consecutiveSameTool = CountConsecutiveSameTool(toolCalls);
 15107        var perToolFailureRate = BuildPerToolFailureRate(toolCalls);
 15108        var (p50, p95) = ComputeLatencyPercentiles(toolCalls);
 109
 15110        var totalMetric = new NumericMetric(
 15111            TotalMetricName,
 15112            value: total,
 15113            reason: total == 0
 15114                ? "No tool calls were recorded for this agent run."
 15115                : $"{total} tool call(s) were recorded.");
 116
 15117        var failedMetric = new NumericMetric(
 15118            FailedMetricName,
 15119            value: failed,
 15120            reason: failed == 0
 15121                ? "All recorded tool calls succeeded."
 15122                : $"{failed} of {total} recorded tool call(s) failed.");
 123
 15124        var gapsMetric = new NumericMetric(
 15125            SequenceGapsMetricName,
 15126            value: gaps,
 15127            reason: gaps == 0
 15128                ? "The tool-call sequence is contiguous starting at 0."
 15129                : $"{gaps} gap(s) detected in the tool-call sequence.");
 130
 15131        var allSucceededMetric = new BooleanMetric(
 15132            AllSucceededMetricName,
 15133            value: allSucceeded,
 15134            reason: allSucceeded
 15135                ? "Every recorded tool call reported success."
 15136                : "At least one recorded tool call reported failure.");
 137
 15138        var consecutiveMetric = new NumericMetric(
 15139            ConsecutiveSameToolMetricName,
 15140            value: consecutiveSameTool,
 15141            reason: consecutiveSameTool == 0
 15142                ? "No consecutive same-tool calls detected."
 15143                : $"{consecutiveSameTool} consecutive same-tool call(s) detected (heuristic — may include valid parallel
 144
 15145        var failureRateMetric = new StringMetric(
 15146            PerToolFailureRateMetricName,
 15147            value: perToolFailureRate,
 15148            reason: total == 0
 15149                ? "No tool calls to compute failure rates."
 15150                : "Per-tool failure rates as JSON (tool name → failure rate 0.0–1.0).");
 151
 15152        var p50Metric = new NumericMetric(
 15153            LatencyP50MetricName,
 15154            value: p50,
 15155            reason: total == 0
 15156                ? "No tool calls to compute latency."
 15157                : $"50th percentile tool-call latency: {p50:F1}ms.");
 158
 15159        var p95Metric = new NumericMetric(
 15160            LatencyP95MetricName,
 15161            value: p95,
 15162            reason: total == 0
 15163                ? "No tool calls to compute latency."
 15164                : $"95th percentile tool-call latency: {p95:F1}ms.");
 165
 15166        return new ValueTask<EvaluationResult>(new EvaluationResult(
 15167            totalMetric,
 15168            failedMetric,
 15169            gapsMetric,
 15170            allSucceededMetric,
 15171            consecutiveMetric,
 15172            failureRateMetric,
 15173            p50Metric,
 15174            p95Metric));
 175    }
 176
 177    private static int CountSequenceGaps(IReadOnlyList<ToolCallDiagnostics> toolCalls)
 178    {
 15179        if (toolCalls.Count == 0)
 180        {
 4181            return 0;
 182        }
 183
 11184        var sequences = new int[toolCalls.Count];
 88185        for (var i = 0; i < toolCalls.Count; i++)
 186        {
 33187            sequences[i] = toolCalls[i].Sequence;
 188        }
 11189        Array.Sort(sequences);
 190
 11191        var gaps = 0;
 11192        var expected = sequences[0];
 88193        for (var i = 0; i < sequences.Length; i++)
 194        {
 33195            var actual = sequences[i];
 33196            if (actual > expected)
 197            {
 2198                gaps += actual - expected;
 199            }
 33200            expected = actual + 1;
 201        }
 202
 11203        return gaps;
 204    }
 205
 206    private static int CountConsecutiveSameTool(IReadOnlyList<ToolCallDiagnostics> toolCalls)
 207    {
 15208        if (toolCalls.Count <= 1)
 209        {
 5210            return 0;
 211        }
 212
 10213        var count = 0;
 64214        for (var i = 1; i < toolCalls.Count; i++)
 215        {
 22216            if (string.Equals(toolCalls[i].ToolName, toolCalls[i - 1].ToolName, StringComparison.Ordinal))
 217            {
 4218                count++;
 219            }
 220        }
 221
 10222        return count;
 223    }
 224
 225    private static string BuildPerToolFailureRate(IReadOnlyList<ToolCallDiagnostics> toolCalls)
 226    {
 15227        if (toolCalls.Count == 0)
 228        {
 4229            return "{}";
 230        }
 231
 11232        var totals = new SortedDictionary<string, int>(StringComparer.Ordinal);
 11233        var failures = new SortedDictionary<string, int>(StringComparer.Ordinal);
 234
 88235        for (var i = 0; i < toolCalls.Count; i++)
 236        {
 33237            var name = toolCalls[i].ToolName;
 33238            totals.TryGetValue(name, out var t);
 33239            totals[name] = t + 1;
 240
 33241            if (!toolCalls[i].Succeeded)
 242            {
 3243                failures.TryGetValue(name, out var f);
 3244                failures[name] = f + 1;
 245            }
 246        }
 247
 11248        var rates = new SortedDictionary<string, double>(StringComparer.Ordinal);
 78249        foreach (var kvp in totals)
 250        {
 28251            failures.TryGetValue(kvp.Key, out var f);
 28252            rates[kvp.Key] = (double)f / kvp.Value;
 253        }
 254
 11255        return JsonSerializer.Serialize(rates);
 256    }
 257
 258    private static (double P50, double P95) ComputeLatencyPercentiles(
 259        IReadOnlyList<ToolCallDiagnostics> toolCalls)
 260    {
 15261        if (toolCalls.Count == 0)
 262        {
 4263            return (0, 0);
 264        }
 265
 11266        var durations = new double[toolCalls.Count];
 88267        for (var i = 0; i < toolCalls.Count; i++)
 268        {
 33269            durations[i] = toolCalls[i].Duration.TotalMilliseconds;
 270        }
 11271        Array.Sort(durations);
 272
 11273        return (NearestRankPercentile(durations, 50), NearestRankPercentile(durations, 95));
 274    }
 275
 276    private static double NearestRankPercentile(double[] sorted, int percentile)
 277    {
 22278        var index = (int)Math.Ceiling(percentile / 100.0 * sorted.Length) - 1;
 22279        return sorted[Math.Clamp(index, 0, sorted.Length - 1)];
 280    }
 281}