< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Evaluation.EvaluationCaptureChatClient
Assembly: NexusLabs.Needlr.AgentFramework.Evaluation
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Evaluation/EvaluationCaptureChatClient.cs
Line coverage
88%
Covered lines: 76
Uncovered lines: 10
Coverable lines: 86
Total lines: 204
Line coverage: 88.3%
Branch coverage
90%
Covered branches: 49
Total branches: 54
Branch coverage: 90.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetResponseAsync()75%44100%
GetStreamingResponseAsync()87.5%88100%
ComputeKey(...)94.11%373485.71%
FormatNullable(...)100%22100%
ToUpdates()83.33%7666.66%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Runtime.CompilerServices;
 3using System.Security.Cryptography;
 4using System.Text;
 5using System.Text.Json;
 6
 7using Microsoft.Extensions.AI;
 8
 9namespace NexusLabs.Needlr.AgentFramework.Evaluation;
 10
 11/// <summary>
 12/// <see cref="DelegatingChatClient"/> that persists every LLM request/response pair
 13/// to an <see cref="IEvaluationCaptureStore"/> and replays cached responses on
 14/// subsequent calls with an identical request. Intended to make evaluator runs
 15/// deterministic and cheap to re-execute.
 16/// </summary>
 17/// <remarks>
 18/// <para>
 19/// Cache keys are derived from a stable SHA-256 hash of the request messages
 20/// (role + text) and a small subset of <see cref="ChatOptions"/> that affect
 21/// output — currently <c>ModelId</c>, <c>Temperature</c>, <c>TopP</c>, and
 22/// <c>MaxOutputTokens</c>. Requests that differ only in non-captured options
 23/// will collide; callers that rely on other options producing distinct responses
 24/// must not use this middleware.
 25/// </para>
 26/// <para>
 27/// Streaming calls materialize cached responses as a single
 28/// <see cref="ChatResponseUpdate"/> per message. On cache miss the stream is
 29/// fully buffered before being persisted and replayed to the caller.
 30/// </para>
 31/// </remarks>
 32public sealed class EvaluationCaptureChatClient : DelegatingChatClient
 33{
 34    private readonly IEvaluationCaptureStore _store;
 35
 36    /// <param name="innerClient">The inner chat client to delegate to.</param>
 37    /// <param name="store">Backing store used for capture and replay.</param>
 38    public EvaluationCaptureChatClient(
 39        IChatClient innerClient,
 40        IEvaluationCaptureStore store)
 1241        : base(innerClient)
 42    {
 1243        ArgumentNullException.ThrowIfNull(store);
 1144        _store = store;
 1145    }
 46
 47    /// <inheritdoc />
 48    public override async Task<ChatResponse> GetResponseAsync(
 49        IEnumerable<ChatMessage> messages,
 50        ChatOptions? options = null,
 51        CancellationToken cancellationToken = default)
 52    {
 953        ArgumentNullException.ThrowIfNull(messages);
 54
 855        var materialized = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
 856        var key = ComputeKey(materialized, options);
 57
 858        var cached = await _store.TryGetAsync(key, cancellationToken).ConfigureAwait(false);
 859        if (cached is not null)
 60        {
 461            return cached;
 62        }
 63
 464        var response = await base
 465            .GetResponseAsync(materialized, options, cancellationToken)
 466            .ConfigureAwait(false);
 67
 468        await _store.SaveAsync(key, response, cancellationToken).ConfigureAwait(false);
 469        return response;
 870    }
 71
 72    /// <inheritdoc />
 73    public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
 74        IEnumerable<ChatMessage> messages,
 75        ChatOptions? options = null,
 76        [EnumeratorCancellation] CancellationToken cancellationToken = default)
 77    {
 378        ArgumentNullException.ThrowIfNull(messages);
 79
 280        var materialized = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
 281        var key = ComputeKey(materialized, options);
 82
 283        var cached = await _store.TryGetAsync(key, cancellationToken).ConfigureAwait(false);
 284        if (cached is not null)
 85        {
 486            foreach (var update in ToUpdates(cached))
 87            {
 188                yield return update;
 89            }
 190            yield break;
 91        }
 92
 193        var buffered = new List<ChatResponseUpdate>();
 494        await foreach (var update in base
 195            .GetStreamingResponseAsync(materialized, options, cancellationToken)
 196            .ConfigureAwait(false))
 97        {
 198            buffered.Add(update);
 199            yield return update;
 100        }
 101
 1102        var combined = buffered.ToChatResponse();
 1103        await _store.SaveAsync(key, combined, cancellationToken).ConfigureAwait(false);
 2104    }
 105
 106    internal static string ComputeKey(
 107        IReadOnlyList<ChatMessage> messages,
 108        ChatOptions? options)
 109    {
 26110        var sb = new StringBuilder();
 108111        foreach (var message in messages)
 112        {
 28113            sb.Append(message.Role.Value);
 28114            sb.Append(':');
 28115            sb.Append(message.Text);
 116
 114117            foreach (var content in message.Contents)
 118            {
 119                switch (content)
 120                {
 121                    case FunctionCallContent fc:
 2122                        sb.Append("|fc:");
 2123                        sb.Append(fc.CallId);
 2124                        sb.Append(':');
 2125                        sb.Append(fc.Name);
 2126                        if (fc.Arguments is not null)
 127                        {
 2128                            sb.Append(':');
 10129                            foreach (var kvp in fc.Arguments.OrderBy(k => k.Key, StringComparer.Ordinal))
 130                            {
 2131                                sb.Append(kvp.Key).Append('=').Append(kvp.Value).Append(';');
 132                            }
 133                        }
 134                        break;
 135                    case FunctionResultContent fr:
 0136                        sb.Append("|fr:");
 0137                        sb.Append(fr.CallId);
 0138                        sb.Append(':');
 0139                        sb.Append(fr.Result);
 0140                        break;
 141                    case TextReasoningContent reasoning:
 3142                        sb.Append("|reason:");
 3143                        sb.Append(reasoning.Text);
 3144                        break;
 145#pragma warning disable MEAI001 // WebSearchToolCallContent is experimental
 146                    case WebSearchToolCallContent ws:
 2147                        sb.Append("|ws:");
 2148                        if (ws.Queries is not null)
 149                        {
 8150                            foreach (var query in ws.Queries)
 151                            {
 2152                                sb.Append(query).Append(';');
 153                            }
 154                        }
 155                        break;
 156#pragma warning restore MEAI001
 157                }
 158            }
 159
 28160            sb.Append('\n');
 161        }
 162
 26163        sb.Append("---\n");
 26164        sb.Append("model:").Append(options?.ModelId ?? string.Empty).Append('\n');
 26165        sb.Append("temp:").Append(FormatNullable(options?.Temperature)).Append('\n');
 26166        sb.Append("topp:").Append(FormatNullable(options?.TopP)).Append('\n');
 26167        sb.Append("max:").Append(options?.MaxOutputTokens?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).Appen
 168
 26169        var bytes = Encoding.UTF8.GetBytes(sb.ToString());
 26170        var hash = SHA256.HashData(bytes);
 26171        return Convert.ToHexString(hash).ToLowerInvariant();
 172    }
 173
 174    private static string FormatNullable(float? value) =>
 52175        value.HasValue
 52176            ? value.Value.ToString("R", CultureInfo.InvariantCulture)
 52177            : string.Empty;
 178
 179    private static IEnumerable<ChatResponseUpdate> ToUpdates(ChatResponse response)
 180    {
 4181        foreach (var message in response.Messages)
 182        {
 1183            if (message.Contents.Count > 0)
 184            {
 4185                foreach (var content in message.Contents)
 186                {
 1187                    yield return new ChatResponseUpdate(message.Role, [content])
 1188                    {
 1189                        ResponseId = response.ResponseId,
 1190                        ModelId = response.ModelId,
 1191                    };
 192                }
 193            }
 194            else
 195            {
 0196                yield return new ChatResponseUpdate(message.Role, message.Text)
 0197                {
 0198                    ResponseId = response.ResponseId,
 0199                    ModelId = response.ModelId,
 0200                };
 201            }
 1202        }
 1203    }
 204}