< 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
75%
Covered lines: 91
Uncovered lines: 30
Coverable lines: 121
Total lines: 260
Line coverage: 75.2%
Branch coverage
80%
Covered branches: 72
Total branches: 90
Branch coverage: 80%
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()50%9655%
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>
 339    internal CopilotWebSearchFunction(CopilotMcpToolClient mcpClient)
 40    {
 341        _mcpClient = mcpClient ?? throw new ArgumentNullException(nameof(mcpClient));
 342    }
 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    {
 261        var query = arguments.TryGetValue("query", out var queryValue)
 262            ? queryValue?.ToString()
 263            : null;
 64
 265        if (string.IsNullOrWhiteSpace(query))
 66        {
 067            return "Error: 'query' parameter is required.";
 68        }
 69
 70        try
 71        {
 272            var result = await _mcpClient.CallToolAsync(
 273                "web_search",
 274                new Dictionary<string, string> { ["query"] = query },
 275                "web_search",
 276                cancellationToken).ConfigureAwait(false);
 77
 078            var searchResult = ParseSearchResult(result);
 079            ThrowIfRateLimited(searchResult);
 080            return searchResult;
 81        }
 082        catch (CopilotRateLimitException)
 83        {
 084            throw;
 85        }
 286        catch (CopilotAuthException)
 87        {
 288            throw;
 89        }
 090        catch (Exception ex)
 91        {
 092            return $"Web search failed: {ex.Message}";
 93        }
 094    }
 95
 96    internal static void ThrowIfRateLimited(WebSearchResult result)
 97    {
 598        if (result.Text.StartsWith("Rate limit exceeded", StringComparison.OrdinalIgnoreCase) ||
 599            result.Text.StartsWith("Too many requests", StringComparison.OrdinalIgnoreCase))
 100        {
 101            // Only trigger for genuine rate-limit responses: these will have
 102            // no citations and no search queries. A real search result that
 103            // discusses rate limiting will have structured citation data.
 3104            if (result.Citations.Count == 0 && result.SearchQueries.Count == 0)
 105            {
 3106                var retryAfter = CopilotRateLimitException.ParseRetryAfterFromText(result.Text);
 3107                throw new CopilotRateLimitException(
 3108                    $"Copilot web search rate limited: {result.Text}",
 3109                    retryAfter);
 110            }
 111        }
 2112    }
 113
 114    internal static WebSearchResult ParseSearchResult(string mcpResultText)
 115    {
 16116        string text = mcpResultText;
 16117        List<WebSearchCitation>? citations = null;
 16118        List<WebSearchQuery>? searchQueries = null;
 119
 120        try
 121        {
 16122            using var doc = JsonDocument.Parse(mcpResultText);
 12123            var root = doc.RootElement;
 124
 125            // Extract the answer text from text.value (preferred) or text as a
 126            // direct string (fallback).
 12127            if (root.TryGetProperty("text", out var textObj))
 128            {
 12129                if (textObj.ValueKind == JsonValueKind.Object &&
 12130                    textObj.TryGetProperty("value", out var valueElement))
 131                {
 11132                    text = valueElement.GetString() ?? mcpResultText;
 133
 134                    // Parse text.annotations (citation data)
 11135                    citations = ParseCitations(textObj);
 136                }
 1137                else if (textObj.ValueKind == JsonValueKind.String)
 138                {
 1139                    text = textObj.GetString() ?? mcpResultText;
 140                }
 141            }
 142
 143            // Fall back to root-level annotations if text.annotations was
 144            // absent or empty, in case the API shape evolves.
 12145            if ((citations is null || citations.Count == 0) &&
 12146                root.TryGetProperty("annotations", out var rootAnnotations))
 147            {
 2148                citations = ParseCitations(rootAnnotations);
 149            }
 150
 151            // Parse bing_searches
 12152            if (root.TryGetProperty("bing_searches", out var bingSearches) &&
 12153                bingSearches.ValueKind == JsonValueKind.Array)
 154            {
 4155                searchQueries = ParseSearchQueries(bingSearches);
 156            }
 12157        }
 4158        catch (JsonException)
 159        {
 160            // Best-effort: return whatever we have so far.
 4161        }
 162
 16163        return new WebSearchResult(
 16164            text,
 16165            citations?.AsReadOnly() ?? (IReadOnlyList<WebSearchCitation>)Array.Empty<WebSearchCitation>(),
 16166            searchQueries?.AsReadOnly() ?? (IReadOnlyList<WebSearchQuery>)Array.Empty<WebSearchQuery>());
 167    }
 168
 169    private static List<WebSearchCitation>? ParseCitations(JsonElement parent)
 170    {
 171        JsonElement annotations;
 13172        if (parent.ValueKind == JsonValueKind.Object)
 173        {
 11174            if (!parent.TryGetProperty("annotations", out annotations))
 175            {
 4176                return null;
 177            }
 178        }
 2179        else if (parent.ValueKind == JsonValueKind.Array)
 180        {
 1181            annotations = parent;
 182        }
 183        else
 184        {
 1185            return null;
 186        }
 187
 8188        if (annotations.ValueKind != JsonValueKind.Array)
 189        {
 1190            return null;
 191        }
 192
 7193        var citations = new List<WebSearchCitation>();
 32194        foreach (var ann in annotations.EnumerateArray())
 195        {
 196            try
 197            {
 9198                if (!ann.TryGetProperty("url_citation", out var urlCitation))
 199                {
 1200                    continue;
 201                }
 202
 8203                var title = urlCitation.TryGetProperty("title", out var t)
 8204                    ? t.GetString() ?? ""
 8205                    : "";
 8206                var url = urlCitation.TryGetProperty("url", out var u)
 8207                    ? u.GetString() ?? ""
 8208                    : "";
 8209                var startIndex = ann.TryGetProperty("start_index", out var si)
 8210                    && si.ValueKind == JsonValueKind.Number
 8211                    && si.TryGetInt32(out var siVal)
 8212                    ? siVal
 8213                    : 0;
 8214                var endIndex = ann.TryGetProperty("end_index", out var ei)
 8215                    && ei.ValueKind == JsonValueKind.Number
 8216                    && ei.TryGetInt32(out var eiVal)
 8217                    ? eiVal
 8218                    : 0;
 219
 8220                citations.Add(new WebSearchCitation(title, url, startIndex, endIndex));
 8221            }
 0222            catch (Exception ex) when (ex is JsonException or InvalidOperationException)
 223            {
 224                // Skip malformed individual citations.
 0225            }
 226        }
 227
 7228        return citations;
 229    }
 230
 231    private static List<WebSearchQuery>? ParseSearchQueries(JsonElement bingSearches)
 232    {
 4233        if (bingSearches.ValueKind != JsonValueKind.Array)
 234        {
 0235            return null;
 236        }
 237
 4238        var queries = new List<WebSearchQuery>();
 18239        foreach (var item in bingSearches.EnumerateArray())
 240        {
 241            try
 242            {
 5243                var queryText = item.TryGetProperty("text", out var t)
 5244                    ? t.GetString() ?? ""
 5245                    : "";
 5246                var url = item.TryGetProperty("url", out var u)
 5247                    ? u.GetString() ?? ""
 5248                    : "";
 249
 5250                queries.Add(new WebSearchQuery(queryText, url));
 5251            }
 0252            catch (JsonException)
 253            {
 254                // Skip malformed individual search entries.
 0255            }
 256        }
 257
 4258        return queries;
 259    }
 260}