< Summary

Information
Class: NexusLabs.Needlr.Copilot.CopilotWebSearchFunction
Assembly: NexusLabs.Needlr.Copilot
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotWebSearchFunction.cs
Line coverage
67%
Covered lines: 80
Uncovered lines: 39
Coverable lines: 119
Total lines: 256
Line coverage: 67.2%
Branch coverage
76%
Covered branches: 69
Total branches: 90
Branch coverage: 76.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)50%22100%
get_Name()100%11100%
get_Description()100%210%
get_JsonSchema()100%210%
InvokeCoreAsync()0%4260%
ThrowIfRateLimited(...)100%88100%
ParseSearchResult(...)93.33%3030100%
ParseCitations(...)78.12%323293.93%
ParseSearchQueries(...)58.33%131280%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotWebSearchFunction.cs

#LineLine coverage
 1using System.Text.Json;
 2
 3using Microsoft.Extensions.AI;
 4
 5namespace NexusLabs.Needlr.Copilot;
 6
 7/// <summary>
 8/// <see cref="AIFunction"/> that calls the Copilot MCP server's <c>web_search</c> tool.
 9/// Returns a <see cref="WebSearchResult"/> containing the answer text, source
 10/// citations, and the search queries that were performed.
 11/// </summary>
 12/// <example>
 13/// <code>
 14/// var tools = CopilotToolSet.Create(new CopilotToolSetOptions { EnableWebSearch = true });
 15/// // Pass tools to an agent via IterativeLoopOptions.Tools
 16/// </code>
 17/// </example>
 18public sealed class CopilotWebSearchFunction : AIFunction
 19{
 20    private readonly CopilotMcpToolClient _mcpClient;
 21
 022    private static readonly JsonElement _schema = JsonDocument.Parse("""
 023        {
 024            "type": "object",
 025            "properties": {
 026                "query": {
 027                    "type": "string",
 028                    "description": "A clear, specific question or prompt that requires up-to-date information from the w
 029                }
 030            },
 031            "required": ["query"]
 032        }
 033        """).RootElement.Clone();
 34
 35    /// <summary>
 36    /// Creates a new <see cref="CopilotWebSearchFunction"/> backed by the given MCP client.
 37    /// </summary>
 38    /// <param name="mcpClient">The MCP client used to call the Copilot web search endpoint.</param>
 139    internal CopilotWebSearchFunction(CopilotMcpToolClient mcpClient)
 40    {
 141        _mcpClient = mcpClient ?? throw new ArgumentNullException(nameof(mcpClient));
 142    }
 43
 44    /// <inheritdoc />
 145    public override string Name => "web_search";
 46
 47    /// <inheritdoc />
 48    public override string Description =>
 049        "Performs an AI-powered web search to provide intelligent, contextual answers with citations. " +
 050        "Use when the query pertains to recent events, new developments, niche subjects, or when " +
 051        "current factual information with verifiable sources is needed.";
 52
 53    /// <inheritdoc />
 054    public override JsonElement JsonSchema => _schema;
 55
 56    /// <inheritdoc />
 57    protected override async ValueTask<object?> InvokeCoreAsync(
 58        AIFunctionArguments arguments,
 59        CancellationToken cancellationToken)
 60    {
 061        var query = arguments.TryGetValue("query", out var queryValue)
 062            ? queryValue?.ToString()
 063            : null;
 64
 065        if (string.IsNullOrWhiteSpace(query))
 66        {
 067            return "Error: 'query' parameter is required.";
 68        }
 69
 70        try
 71        {
 072            var result = await _mcpClient.CallToolAsync(
 073                "web_search",
 074                new Dictionary<string, string> { ["query"] = query },
 075                "web_search",
 076                cancellationToken).ConfigureAwait(false);
 77
 078            var searchResult = ParseSearchResult(result);
 079            ThrowIfRateLimited(searchResult);
 080            return searchResult;
 81        }
 082        catch (CopilotRateLimitException)
 83        {
 084            throw;
 85        }
 086        catch (Exception ex)
 87        {
 088            return $"Web search failed: {ex.Message}";
 89        }
 090    }
 91
 92    internal static void ThrowIfRateLimited(WebSearchResult result)
 93    {
 594        if (result.Text.StartsWith("Rate limit exceeded", StringComparison.OrdinalIgnoreCase) ||
 595            result.Text.StartsWith("Too many requests", StringComparison.OrdinalIgnoreCase))
 96        {
 97            // Only trigger for genuine rate-limit responses: these will have
 98            // no citations and no search queries. A real search result that
 99            // discusses rate limiting will have structured citation data.
 3100            if (result.Citations.Count == 0 && result.SearchQueries.Count == 0)
 101            {
 3102                var retryAfter = CopilotRateLimitException.ParseRetryAfterFromText(result.Text);
 3103                throw new CopilotRateLimitException(
 3104                    $"Copilot web search rate limited: {result.Text}",
 3105                    retryAfter);
 106            }
 107        }
 2108    }
 109
 110    internal static WebSearchResult ParseSearchResult(string mcpResultText)
 111    {
 16112        string text = mcpResultText;
 16113        List<WebSearchCitation>? citations = null;
 16114        List<WebSearchQuery>? searchQueries = null;
 115
 116        try
 117        {
 16118            using var doc = JsonDocument.Parse(mcpResultText);
 12119            var root = doc.RootElement;
 120
 121            // Extract the answer text from text.value (preferred) or text as a
 122            // direct string (fallback).
 12123            if (root.TryGetProperty("text", out var textObj))
 124            {
 12125                if (textObj.ValueKind == JsonValueKind.Object &&
 12126                    textObj.TryGetProperty("value", out var valueElement))
 127                {
 11128                    text = valueElement.GetString() ?? mcpResultText;
 129
 130                    // Parse text.annotations (citation data)
 11131                    citations = ParseCitations(textObj);
 132                }
 1133                else if (textObj.ValueKind == JsonValueKind.String)
 134                {
 1135                    text = textObj.GetString() ?? mcpResultText;
 136                }
 137            }
 138
 139            // Fall back to root-level annotations if text.annotations was
 140            // absent or empty, in case the API shape evolves.
 12141            if ((citations is null || citations.Count == 0) &&
 12142                root.TryGetProperty("annotations", out var rootAnnotations))
 143            {
 2144                citations = ParseCitations(rootAnnotations);
 145            }
 146
 147            // Parse bing_searches
 12148            if (root.TryGetProperty("bing_searches", out var bingSearches) &&
 12149                bingSearches.ValueKind == JsonValueKind.Array)
 150            {
 4151                searchQueries = ParseSearchQueries(bingSearches);
 152            }
 12153        }
 4154        catch (JsonException)
 155        {
 156            // Best-effort: return whatever we have so far.
 4157        }
 158
 16159        return new WebSearchResult(
 16160            text,
 16161            citations?.AsReadOnly() ?? (IReadOnlyList<WebSearchCitation>)Array.Empty<WebSearchCitation>(),
 16162            searchQueries?.AsReadOnly() ?? (IReadOnlyList<WebSearchQuery>)Array.Empty<WebSearchQuery>());
 163    }
 164
 165    private static List<WebSearchCitation>? ParseCitations(JsonElement parent)
 166    {
 167        JsonElement annotations;
 13168        if (parent.ValueKind == JsonValueKind.Object)
 169        {
 11170            if (!parent.TryGetProperty("annotations", out annotations))
 171            {
 4172                return null;
 173            }
 174        }
 2175        else if (parent.ValueKind == JsonValueKind.Array)
 176        {
 1177            annotations = parent;
 178        }
 179        else
 180        {
 1181            return null;
 182        }
 183
 8184        if (annotations.ValueKind != JsonValueKind.Array)
 185        {
 1186            return null;
 187        }
 188
 7189        var citations = new List<WebSearchCitation>();
 32190        foreach (var ann in annotations.EnumerateArray())
 191        {
 192            try
 193            {
 9194                if (!ann.TryGetProperty("url_citation", out var urlCitation))
 195                {
 1196                    continue;
 197                }
 198
 8199                var title = urlCitation.TryGetProperty("title", out var t)
 8200                    ? t.GetString() ?? ""
 8201                    : "";
 8202                var url = urlCitation.TryGetProperty("url", out var u)
 8203                    ? u.GetString() ?? ""
 8204                    : "";
 8205                var startIndex = ann.TryGetProperty("start_index", out var si)
 8206                    && si.ValueKind == JsonValueKind.Number
 8207                    && si.TryGetInt32(out var siVal)
 8208                    ? siVal
 8209                    : 0;
 8210                var endIndex = ann.TryGetProperty("end_index", out var ei)
 8211                    && ei.ValueKind == JsonValueKind.Number
 8212                    && ei.TryGetInt32(out var eiVal)
 8213                    ? eiVal
 8214                    : 0;
 215
 8216                citations.Add(new WebSearchCitation(title, url, startIndex, endIndex));
 8217            }
 0218            catch (Exception ex) when (ex is JsonException or InvalidOperationException)
 219            {
 220                // Skip malformed individual citations.
 0221            }
 222        }
 223
 7224        return citations;
 225    }
 226
 227    private static List<WebSearchQuery>? ParseSearchQueries(JsonElement bingSearches)
 228    {
 4229        if (bingSearches.ValueKind != JsonValueKind.Array)
 230        {
 0231            return null;
 232        }
 233
 4234        var queries = new List<WebSearchQuery>();
 18235        foreach (var item in bingSearches.EnumerateArray())
 236        {
 237            try
 238            {
 5239                var queryText = item.TryGetProperty("text", out var t)
 5240                    ? t.GetString() ?? ""
 5241                    : "";
 5242                var url = item.TryGetProperty("url", out var u)
 5243                    ? u.GetString() ?? ""
 5244                    : "";
 245
 5246                queries.Add(new WebSearchQuery(queryText, url));
 5247            }
 0248            catch (JsonException)
 249            {
 250                // Skip malformed individual search entries.
 0251            }
 252        }
 253
 4254        return queries;
 255    }
 256}