From d674e4decf957a1c2d45385b0f6e3c6244d32d42 Mon Sep 17 00:00:00 2001 From: Mani Date: Tue, 27 Oct 2026 12:41:22 -0400 Subject: [PATCH 1/8] .Net: Fix Tavily text search skip window, top limit and image results - Apply Skip client-side: request top + skip results (capped at the Tavily max_results ceiling of 20) and drop the first skip results, instead of sending max_results = top - skip. - Validate Top against the Tavily maximum of 20 instead of 50. - Parenthesize IncludeImages checks so a missing images array no longer throws when IncludeImages is true. - Add Fast and UltraFast search depths (serialized as "fast" and "ultra-fast") and a Country option. Co-Authored-By: Claude Sonnet 5.5 --- .../Web/Tavily/TavilyTextSearchTests.cs | 108 ++++++++++++++++++ .../Plugins.Web/Tavily/TavilySearchDepth.cs | 12 +- .../Plugins.Web/Tavily/TavilySearchRequest.cs | 10 +- .../Plugins.Web/Tavily/TavilyTextSearch.cs | 43 +++++-- .../Tavily/TavilyTextSearchOptions.cs | 9 +- 5 files changed, 169 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index be5c78d41d0f..a304504dd5e7 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -340,6 +340,114 @@ public async Task DoesNotBuildRequestForInvalidQueryAsync() Assert.Equal("Value cannot be null. (Parameter 'query')", e.Message); } + [Theory] + [InlineData(2, 1, 3, new[] { 1, 2 })] + [InlineData(2, 0, 2, new[] { 0, 1 })] + [InlineData(3, 3, 6, new[] { 3 })] + [InlineData(15, 10, 20, new[] { 3 })] + public async Task SkipIsAppliedToResultsWithinMaxResultsWindowAsync(int top, int skip, int expectedMaxResults, int[] expectedIndexes) + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var allTitles = JsonDocument.Parse(File.ReadAllText(WhatIsTheSKResponseJson)).RootElement + .GetProperty("results").EnumerateArray().Select(r => r.GetProperty("title").GetString()).ToArray(); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = top, Skip = skip }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.Contains($"\"max_results\":{expectedMaxResults}", requestBodyJson); + var names = (await result.Results.ToListAsync()).Select(r => r.Name).ToArray(); + Assert.Equal(expectedIndexes.Select(i => allTitles[i]).ToArray(), names); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(21)] + [InlineData(50)] + public async Task TopOutsideTavilyMaxResultsRangeThrowsAsync(int top) + { + // Arrange + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = top, Skip = 0 })); + Assert.Empty(this._messageHandlerStub.RequestContents); + } + + [Fact] + public async Task IncludeImagesWithoutImagesInResponseDoesNotThrowAsync() + { + // Arrange + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node.Remove("images"); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults objects = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + var objectList = await objects.Results.ToListAsync(); + KernelSearchResults pages = await ((ITextSearch)textSearch).GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); + var pageList = await pages.Results.ToListAsync(); + + // Assert + Assert.Equal(4, objectList.Count); + Assert.Equal(4, pageList.Count); + } + + [Fact] + public async Task IncludeImagesWithImagesInResponseAppendsImageResultsAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + Assert.Equal(9, (await result.Results.ToListAsync()).Count); + } + + [Theory] + [InlineData(TavilySearchDepth.Basic, "basic")] + [InlineData(TavilySearchDepth.Advanced, "advanced")] + [InlineData(TavilySearchDepth.Fast, "fast")] + [InlineData(TavilySearchDepth.UltraFast, "ultra-fast")] + public async Task SearchDepthIsSerializedWithApiValueAsync(TavilySearchDepth depth, string expected) + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, SearchDepth = depth }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.Contains($"\"search_depth\":\"{expected}\"", requestBodyJson); + } + + [Fact] + public async Task CountryIsSentWhenConfiguredAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, Country = "united states" }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.Contains("\"country\":\"united states\"", requestBodyJson); + } + /// public void Dispose() { diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchDepth.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchDepth.cs index fce136d68ad3..cb629fe9d7f5 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchDepth.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchDepth.cs @@ -16,5 +16,15 @@ public enum TavilySearchDepth /// /// Advanced search costs 2 API Credits. /// - Advanced + Advanced, + + /// + /// Fast search prioritizes lower latency while maintaining good relevance. Costs 1 API Credit. + /// + Fast, + + /// + /// Ultra-fast search minimizes latency above all else. Costs 1 API Credit. + /// + UltraFast } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs index b7416895bd4d..4692656deb41 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs @@ -88,6 +88,12 @@ internal sealed class TavilySearchRequest [JsonPropertyName("include_image_descriptions")] public bool? IncludeImageDescriptions { get; set; } + /// + /// Boost search results from a specific country. Available only when topic is general. + /// + [JsonPropertyName("country")] + public string? Country { get; set; } + /// /// A list of domains to specifically include in the search results. /// @@ -122,7 +128,8 @@ public TavilySearchRequest( bool? includeRawContent, int? maxResults, IList? includeDomains, - IList? excludeDomains) + IList? excludeDomains, + string? country = null) { this.Query = query ?? throw new ArgumentNullException(nameof(query)); this.Topic = topic; @@ -137,5 +144,6 @@ public TavilySearchRequest( this.MaxResults = maxResults; this.IncludeDomains = includeDomains; this.ExcludeDomains = excludeDomains; + this.Country = country; } } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index 41fd5cd35df1..c6ab4f70738e 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Linq.Expressions; using System.Net.Http; using System.Runtime.CompilerServices; @@ -395,6 +396,11 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis private const string DefaultUri = "https://api.tavily.com/search"; + /// + /// The maximum value of the Tavily max_results request parameter. + /// + private const int MaxResultsLimit = 20; + private const string Topic = "topic"; private const string TimeRange = "time_range"; private const string Days = "days"; @@ -422,7 +428,13 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis // Sensitive data, logging as trace, disabled by default this._logger.LogTrace("Response content received: {Data}", json); - return JsonSerializer.Deserialize(json); + TavilySearchResponse? searchResponse = JsonSerializer.Deserialize(json); + if (skip > 0 && searchResponse?.Results is not null) + { + searchResponse.Results = [.. searchResponse.Results.Skip(skip).Take(top)]; + } + + return searchResponse; } /// @@ -436,9 +448,9 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis /// A representing the response from the request. private async Task SendGetRequestAsync(string query, int top, int skip, List<(string FieldName, object Value)> filters, CancellationToken cancellationToken) { - if (top is <= 0 or > 50) + if (top is <= 0 or > MaxResultsLimit) { - throw new ArgumentOutOfRangeException(nameof(top), top, $"{nameof(top)} count value must be greater than 0 and have a maximum value of 50."); + throw new ArgumentOutOfRangeException(nameof(top), top, $"{nameof(top)} count value must be greater than 0 and have a maximum value of {MaxResultsLimit}."); } var requestContent = this.BuildRequestContent(query, top, skip, filters); @@ -474,7 +486,7 @@ private async IAsyncEnumerable GetSearchResultsAsync(TavilySearchRespons await Task.Yield(); } - if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + if ((this._searchOptions?.IncludeImages ?? false) && searchResponse.Images is not null) { foreach (var image in searchResponse.Images!) { @@ -502,7 +514,7 @@ private async IAsyncEnumerable GetResultsAsWebPageAsync(TavilySea await Task.Yield(); } - if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + if ((this._searchOptions?.IncludeImages ?? false) && searchResponse.Images is not null) { foreach (var image in searchResponse.Images!) { @@ -537,7 +549,7 @@ private async IAsyncEnumerable GetResultsAsTextSearchResultAsy await Task.Yield(); } - if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + if ((this._searchOptions?.IncludeImages ?? false) && searchResponse.Images is not null) { foreach (var image in searchResponse.Images!) { @@ -571,7 +583,7 @@ private async IAsyncEnumerable GetResultsAsStringAsync(TavilySearchRespo await Task.Yield(); } - if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + if ((this._searchOptions?.IncludeImages ?? false) && searchResponse.Images is not null) { foreach (var image in searchResponse.Images!) { @@ -676,7 +688,9 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, string? topic = null; string? timeRange = null; int? days = null; - int? maxResults = top - skip; + // Tavily has no offset parameter, so skipped results are requested and then dropped client-side. + // The window is limited by max_results, so top + skip beyond MaxResultsLimit returns fewer than top results. + int? maxResults = Math.Min(top + Math.Max(skip, 0), MaxResultsLimit); IList? includeDomains = null; IList? excludeDomains = null; @@ -718,7 +732,7 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, timeRange, days, #pragma warning disable CA1308 // Lower is preferred over uppercase - this._searchOptions?.SearchDepth?.ToString()?.ToLowerInvariant(), + this._searchOptions?.SearchDepth is { } searchDepth ? ToApiSearchDepth(searchDepth) : null, #pragma warning restore CA1308 this._searchOptions?.ChunksPerSource, this._searchOptions?.IncludeImages, @@ -727,9 +741,18 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, this._searchOptions?.IncludeRawContent, maxResults, includeDomains, - excludeDomains); + excludeDomains, + this._searchOptions?.Country); } + private static string ToApiSearchDepth(TavilySearchDepth searchDepth) => searchDepth switch + { + TavilySearchDepth.UltraFast => "ultra-fast", +#pragma warning disable CA1308 // Lower is preferred over uppercase + _ => searchDepth.ToString().ToLowerInvariant(), +#pragma warning restore CA1308 + }; + private static readonly JsonSerializerOptions s_jsonOptionsCache = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs index 04604088fb33..7d526dba3cfc 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs @@ -30,10 +30,17 @@ public sealed class TavilyTextSearchOptions /// The number of content chunks to retrieve from each source. /// Each chunk's length is maximum 500 characters. /// Available only when search_depth is advanced. - /// Required range: 0 - 3 + /// Available when search_depth is basic, advanced or fast. + /// Required range: 1 - 3 /// public int? ChunksPerSource { get; set; } + /// + /// Boost search results from a specific country, for example "united states". + /// Available only when the search topic is general. + /// + public string? Country { get; set; } + /// /// Include an LLM-generated answer to the provided query. /// basic or true returns a quick answer. advanced returns a more detailed answer. From 25f9e84e6b86ba08be45a9160dd164001ff9a98d Mon Sep 17 00:00:00 2001 From: Mani Date: Tue, 27 Oct 2026 12:52:28 -0400 Subject: [PATCH 2/8] .Net: Keep Top range, cap outgoing window, add Tavily search options - Keep the accepted Top range (1-50) unchanged; cap only the outgoing max_results request window at 20 using overflow-safe arithmetic. - Make the skip window tests internally consistent. - Remove the contradictory ChunksPerSource availability comment. - Add StartDate, EndDate, SafeSearch and IncludeDomainsMode options, serialized only when set. Co-Authored-By: Claude Sonnet 5.5 --- .../Web/Tavily/TavilyTextSearchTests.cs | 77 +++++++++++++++---- .../Tavily/TavilyIncludeDomainsMode.cs | 19 +++++ .../Plugins.Web/Tavily/TavilySearchRequest.cs | 34 +++++++- .../Plugins.Web/Tavily/TavilyTextSearch.cs | 25 ++++-- .../Tavily/TavilyTextSearchOptions.cs | 25 +++++- 5 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 dotnet/src/Plugins/Plugins.Web/Tavily/TavilyIncludeDomainsMode.cs diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index a304504dd5e7..62314e89d618 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -341,16 +341,19 @@ public async Task DoesNotBuildRequestForInvalidQueryAsync() } [Theory] - [InlineData(2, 1, 3, new[] { 1, 2 })] - [InlineData(2, 0, 2, new[] { 0, 1 })] - [InlineData(3, 3, 6, new[] { 3 })] - [InlineData(15, 10, 20, new[] { 3 })] - public async Task SkipIsAppliedToResultsWithinMaxResultsWindowAsync(int top, int skip, int expectedMaxResults, int[] expectedIndexes) + [InlineData(2, 1, 3, 3, 1, 2)] + [InlineData(2, 0, 2, 2, 0, 2)] + [InlineData(3, 3, 6, 6, 3, 3)] + [InlineData(15, 10, 20, 20, 10, 10)] + [InlineData(21, 0, 20, 20, 0, 20)] + [InlineData(50, 0, 20, 20, 0, 20)] + [InlineData(21, 5, 20, 20, 5, 15)] + [InlineData(50, 5, 20, 20, 5, 15)] + [InlineData(3, int.MaxValue, 20, 20, 20, 0)] + public async Task SkipIsAppliedWithinCappedMaxResultsWindowAsync(int top, int skip, int expectedMaxResults, int responseCount, int expectedStart, int expectedCount) { - // Arrange - this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); - var allTitles = JsonDocument.Parse(File.ReadAllText(WhatIsTheSKResponseJson)).RootElement - .GetProperty("results").EnumerateArray().Select(r => r.GetProperty("title").GetString()).ToArray(); + // Arrange - the stub returns as many results as the capped max_results window allows + this._messageHandlerStub.AddJsonResponse(BuildResponseJson(responseCount)); var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act @@ -360,15 +363,14 @@ public async Task SkipIsAppliedToResultsWithinMaxResultsWindowAsync(int top, int var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); Assert.Contains($"\"max_results\":{expectedMaxResults}", requestBodyJson); var names = (await result.Results.ToListAsync()).Select(r => r.Name).ToArray(); - Assert.Equal(expectedIndexes.Select(i => allTitles[i]).ToArray(), names); + Assert.Equal(Enumerable.Range(expectedStart, expectedCount).Select(i => $"Result {i}").ToArray(), names); } [Theory] [InlineData(0)] [InlineData(-1)] - [InlineData(21)] - [InlineData(50)] - public async Task TopOutsideTavilyMaxResultsRangeThrowsAsync(int top) + [InlineData(51)] + public async Task TopOutsideAcceptedRangeStillThrowsAsync(int top) { // Arrange var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); @@ -434,18 +436,61 @@ public async Task SearchDepthIsSerializedWithApiValueAsync(TavilySearchDepth dep } [Fact] - public async Task CountryIsSentWhenConfiguredAsync() + public async Task AdditionalSearchOptionsAreSerializedWhenConfiguredAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); - var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, Country = "united states" }); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() + { + HttpClient = this._httpClient, + Country = "united states", + StartDate = new DateTime(2025, 2, 9), + EndDate = new DateTime(2025, 12, 29), + SafeSearch = true, + IncludeDomainsMode = TavilyIncludeDomainsMode.Prefer, + }); // Act - await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0, Filter = new TextSearchFilter().Equality("include_domain", "devblogs.microsoft.com") }); // Assert var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); Assert.Contains("\"country\":\"united states\"", requestBodyJson); + Assert.Contains("\"start_date\":\"2025-02-09\"", requestBodyJson); + Assert.Contains("\"end_date\":\"2025-12-29\"", requestBodyJson); + Assert.Contains("\"safe_search\":true", requestBodyJson); + Assert.Contains("\"include_domains_mode\":\"prefer\"", requestBodyJson); + } + + [Fact] + public async Task AdditionalSearchOptionsAreOmittedWhenNotConfiguredAsync() + { + // Arrange - include domains mode is configured but there are no include domains in the request + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var unconfigured = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + var modeOnly = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeDomainsMode = TavilyIncludeDomainsMode.Restrict }); + + // Act + await unconfigured.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + await modeOnly.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + foreach (var content in this._messageHandlerStub.RequestContents) + { + var requestBodyJson = Encoding.UTF8.GetString(content!); + Assert.DoesNotContain("country", requestBodyJson); + Assert.DoesNotContain("start_date", requestBodyJson); + Assert.DoesNotContain("end_date", requestBodyJson); + Assert.DoesNotContain("safe_search", requestBodyJson); + Assert.DoesNotContain("include_domains_mode", requestBodyJson); + } + } + + private static string BuildResponseJson(int resultCount) + { + var results = Enumerable.Range(0, resultCount).Select(i => new { title = $"Result {i}", url = $"https://example.com/{i}", content = $"Content {i}", score = 0.5 }); + return JsonSerializer.Serialize(new { query = "q", results, response_time = 0.1 }); } /// diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyIncludeDomainsMode.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyIncludeDomainsMode.cs new file mode 100644 index 000000000000..936633eef8a1 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyIncludeDomainsMode.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; + +/// +/// Controls how the include domain filter is applied to a Tavily search. +/// +public enum TavilyIncludeDomainsMode +{ + /// + /// Limit results to only the included domains (Tavily default). + /// + Restrict, + + /// + /// Also search the rest of the web, so results outside the included domains can still surface. + /// + Prefer +} diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs index 4692656deb41..2415797ed366 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs @@ -94,6 +94,30 @@ internal sealed class TavilySearchRequest [JsonPropertyName("country")] public string? Country { get; set; } + /// + /// Return only results after this date. Format: YYYY-MM-DD. + /// + [JsonPropertyName("start_date")] + public string? StartDate { get; set; } + + /// + /// Return only results before this date. Format: YYYY-MM-DD. + /// + [JsonPropertyName("end_date")] + public string? EndDate { get; set; } + + /// + /// Whether to filter out adult or unsafe content from results. + /// + [JsonPropertyName("safe_search")] + public bool? SafeSearch { get; set; } + + /// + /// How include_domains is applied. Available options: restrict, prefer. + /// + [JsonPropertyName("include_domains_mode")] + public string? IncludeDomainsMode { get; set; } + /// /// A list of domains to specifically include in the search results. /// @@ -129,7 +153,11 @@ public TavilySearchRequest( int? maxResults, IList? includeDomains, IList? excludeDomains, - string? country = null) + string? country = null, + string? startDate = null, + string? endDate = null, + bool? safeSearch = null, + string? includeDomainsMode = null) { this.Query = query ?? throw new ArgumentNullException(nameof(query)); this.Topic = topic; @@ -145,5 +173,9 @@ public TavilySearchRequest( this.IncludeDomains = includeDomains; this.ExcludeDomains = excludeDomains; this.Country = country; + this.StartDate = startDate; + this.EndDate = endDate; + this.SafeSearch = safeSearch; + this.IncludeDomainsMode = includeDomainsMode; } } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index c6ab4f70738e..a46330706337 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Net.Http; @@ -397,7 +398,8 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis private const string DefaultUri = "https://api.tavily.com/search"; /// - /// The maximum value of the Tavily max_results request parameter. + /// The maximum value of the Tavily max_results request parameter. The accepted + /// Top range is unchanged; only the outgoing request window is capped. /// private const int MaxResultsLimit = 20; @@ -448,9 +450,9 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis /// A representing the response from the request. private async Task SendGetRequestAsync(string query, int top, int skip, List<(string FieldName, object Value)> filters, CancellationToken cancellationToken) { - if (top is <= 0 or > MaxResultsLimit) + if (top is <= 0 or > 50) { - throw new ArgumentOutOfRangeException(nameof(top), top, $"{nameof(top)} count value must be greater than 0 and have a maximum value of {MaxResultsLimit}."); + throw new ArgumentOutOfRangeException(nameof(top), top, $"{nameof(top)} count value must be greater than 0 and have a maximum value of 50."); } var requestContent = this.BuildRequestContent(query, top, skip, filters); @@ -690,7 +692,7 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, int? days = null; // Tavily has no offset parameter, so skipped results are requested and then dropped client-side. // The window is limited by max_results, so top + skip beyond MaxResultsLimit returns fewer than top results. - int? maxResults = Math.Min(top + Math.Max(skip, 0), MaxResultsLimit); + int? maxResults = (int)Math.Min((long)top + Math.Max(skip, 0), MaxResultsLimit); IList? includeDomains = null; IList? excludeDomains = null; @@ -742,9 +744,22 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, maxResults, includeDomains, excludeDomains, - this._searchOptions?.Country); + this._searchOptions?.Country, + FormatDate(this._searchOptions?.StartDate), + FormatDate(this._searchOptions?.EndDate), + this._searchOptions?.SafeSearch, + includeDomains is { Count: > 0 } ? ToApiIncludeDomainsMode(this._searchOptions?.IncludeDomainsMode) : null); } + private static string? FormatDate(DateTime? date) => date?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + private static string? ToApiIncludeDomainsMode(TavilyIncludeDomainsMode? mode) => mode switch + { + TavilyIncludeDomainsMode.Restrict => "restrict", + TavilyIncludeDomainsMode.Prefer => "prefer", + _ => null, + }; + private static string ToApiSearchDepth(TavilySearchDepth searchDepth) => searchDepth switch { TavilySearchDepth.UltraFast => "ultra-fast", diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs index 7d526dba3cfc..71beaaf138f8 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs @@ -29,7 +29,6 @@ public sealed class TavilyTextSearchOptions /// /// The number of content chunks to retrieve from each source. /// Each chunk's length is maximum 500 characters. - /// Available only when search_depth is advanced. /// Available when search_depth is basic, advanced or fast. /// Required range: 1 - 3 /// @@ -41,6 +40,30 @@ public sealed class TavilyTextSearchOptions /// public string? Country { get; set; } + /// + /// Return only results published or updated after this date (sent as YYYY-MM-DD). + /// Not sent when null. + /// + public DateTime? StartDate { get; set; } + + /// + /// Return only results published or updated before this date (sent as YYYY-MM-DD). + /// Not sent when null. + /// + public DateTime? EndDate { get; set; } + + /// + /// Whether to filter out adult or unsafe content from results. + /// Not supported by Tavily for the fast or ultra-fast search depths. Not sent when null. + /// + public bool? SafeSearch { get; set; } + + /// + /// Controls how the include domain filter is applied. Only sent when the search has at least + /// one include domain, because Tavily rejects the mode without include domains. Not sent when null. + /// + public TavilyIncludeDomainsMode? IncludeDomainsMode { get; set; } + /// /// Include an LLM-generated answer to the provided query. /// basic or true returns a quick answer. advanced returns a more detailed answer. From 7a56e8bc31c3fc7acee4455ab244704adba0379a Mon Sep 17 00:00:00 2001 From: Mani Date: Tue, 27 Oct 2026 13:06:47 -0400 Subject: [PATCH 3/8] Tavily: send X-Client-Name: semantic-kernel on search requests Adds a per-request X-Client-Name header unless the caller's HttpClient already sets one. User-Agent, Authorization, request bodies and results are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../Web/Tavily/TavilyTextSearchTests.cs | 37 +++++++++++++++++++ .../Plugins.Web/Tavily/TavilyTextSearch.cs | 8 ++++ 2 files changed, 45 insertions(+) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index 62314e89d618..bf77646bf8d6 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -649,6 +649,43 @@ public async Task StringContainsStillWorksWithLINQFiltersAsync() Assert.Contains("\"max_results\":5", requestBodyJson); } + [Fact] + public async Task RequestIdentifiesTheClientAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(BuildResponseJson(1)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 1 }); + + // Assert - partner identity is sent per request; the API key and body are unchanged + var headers = this._messageHandlerStub.RequestHeaders[0]!; + Assert.Equal(new[] { "semantic-kernel" }, headers.GetValues("X-Client-Name").ToArray()); + Assert.Equal("Bearer ApiKey", headers.GetValues("Authorization").Single()); + Assert.False(headers.Contains("X-Client-Source")); + Assert.Contains("User-Agent", this._httpClient.DefaultRequestHeaders.Select(h => h.Key)); + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.DoesNotContain("client_name", requestBodyJson); + Assert.DoesNotContain("client_source", requestBodyJson); + } + + [Fact] + public async Task CallerSuppliedClientNameIsPreservedAsync() + { + // Arrange + this._httpClient.DefaultRequestHeaders.Add("X-Client-Name", "caller-app"); + this._messageHandlerStub.AddJsonResponse(BuildResponseJson(1)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 1 }); + + // Assert - the caller's header is not duplicated or overwritten + Assert.False(this._messageHandlerStub.RequestHeaders[0]!.Contains("X-Client-Name")); + Assert.Equal(new[] { "caller-app" }, this._httpClient.DefaultRequestHeaders.GetValues("X-Client-Name").ToArray()); + } + #endregion #region private diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index a46330706337..11cf4c06544c 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -385,6 +385,8 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis #region Private Methods private readonly ILogger _logger; + private const string ClientNameHeader = "X-Client-Name"; + private const string ClientName = "semantic-kernel"; private readonly HttpClient _httpClient; private readonly string? _apiKey; private readonly Uri? _uri = null; @@ -467,6 +469,12 @@ private async Task SendGetRequestAsync(string query, int to httpRequestMessage.Headers.Add("Authorization", $"Bearer {this._apiKey}"); } + // Identify this connector to Tavily unless the caller already set the header on their HttpClient. + if (!this._httpClient.DefaultRequestHeaders.Contains(ClientNameHeader)) + { + httpRequestMessage.Headers.Add(ClientNameHeader, ClientName); + } + return await this._httpClient.SendWithSuccessCheckAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); } From c612017e79bde2bf367454267dc98f40cf6cda11 Mon Sep 17 00:00:00 2001 From: Mani Date: Tue, 27 Oct 2026 13:08:18 -0400 Subject: [PATCH 4/8] Tavily tests: assert the caller's X-Client-Name reaches the request unduplicated HttpClient merges DefaultRequestHeaders into the request before the handler sees it, so the captured request carries the caller's single value. Co-Authored-By: Claude Sonnet 5.5 --- .../Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index bf77646bf8d6..f70ab262de77 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -681,8 +681,9 @@ public async Task CallerSuppliedClientNameIsPreservedAsync() // Act await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 1 }); - // Assert - the caller's header is not duplicated or overwritten - Assert.False(this._messageHandlerStub.RequestHeaders[0]!.Contains("X-Client-Name")); + // Assert - exactly one X-Client-Name value, the caller's, not duplicated and not semantic-kernel + // HttpClient merges DefaultRequestHeaders into the request before the handler sees it + Assert.Equal(new[] { "caller-app" }, this._messageHandlerStub.RequestHeaders[0]!.GetValues("X-Client-Name").ToArray()); Assert.Equal(new[] { "caller-app" }, this._httpClient.DefaultRequestHeaders.GetValues("X-Client-Name").ToArray()); } From a97e90ea50800c6da496e457eec3576f6447b65a Mon Sep 17 00:00:00 2001 From: Mani Date: Tue, 27 Oct 2026 13:16:03 -0400 Subject: [PATCH 5/8] Tavily: add language, filter_by_language, exact_match and auto_parameters search options Additive nullable options serialized only when set (explicit false is sent). Existing property types, defaults and request bodies are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../Web/Tavily/TavilyTextSearchTests.cs | 61 +++++++++++++++++++ .../Plugins.Web/Tavily/TavilySearchRequest.cs | 34 ++++++++++- .../Plugins.Web/Tavily/TavilyTextSearch.cs | 6 +- .../Tavily/TavilyTextSearchOptions.cs | 30 +++++++++ 4 files changed, 129 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index f70ab262de77..8a5c7d6883fb 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -649,6 +649,67 @@ public async Task StringContainsStillWorksWithLINQFiltersAsync() Assert.Contains("\"max_results\":5", requestBodyJson); } + [Fact] + public async Task LanguageSearchOptionIsSerializedAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, Language = "french" }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.Contains("\"language\":\"french\"", requestBodyJson); + Assert.DoesNotContain("filter_by_language", requestBodyJson); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task BooleanSearchOptionsAreSerializedWhenSetAsync(bool value) + { + // Arrange - explicit false must be sent, not omitted + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() + { + HttpClient = this._httpClient, + Language = "en", + FilterByLanguage = value, + ExactMatch = value, + AutoParameters = value, + }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + var expected = value ? "true" : "false"; + Assert.Contains($"\"filter_by_language\":{expected}", requestBodyJson); + Assert.Contains($"\"exact_match\":{expected}", requestBodyJson); + Assert.Contains($"\"auto_parameters\":{expected}", requestBodyJson); + } + + [Fact] + public async Task LanguageExactMatchAndAutoParametersAreOmittedWhenNotSetAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.DoesNotContain("\"language\"", requestBodyJson); + Assert.DoesNotContain("filter_by_language", requestBodyJson); + Assert.DoesNotContain("exact_match", requestBodyJson); + Assert.DoesNotContain("auto_parameters", requestBodyJson); + } + [Fact] public async Task RequestIdentifiesTheClientAsync() { diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs index 2415797ed366..b6b6134f3cfd 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs @@ -118,6 +118,30 @@ internal sealed class TavilySearchRequest [JsonPropertyName("include_domains_mode")] public string? IncludeDomainsMode { get; set; } + /// + /// Boost search results in a specific language (ISO 639-1 code or English language name). + /// + [JsonPropertyName("language")] + public string? Language { get; set; } + + /// + /// Strictly filter out results that do not match the language instead of only boosting them. + /// + [JsonPropertyName("filter_by_language")] + public bool? FilterByLanguage { get; set; } + + /// + /// Only return results containing the exact quoted phrase(s) in the query. + /// + [JsonPropertyName("exact_match")] + public bool? ExactMatch { get; set; } + + /// + /// Let Tavily configure search parameters automatically from the query. + /// + [JsonPropertyName("auto_parameters")] + public bool? AutoParameters { get; set; } + /// /// A list of domains to specifically include in the search results. /// @@ -157,7 +181,11 @@ public TavilySearchRequest( string? startDate = null, string? endDate = null, bool? safeSearch = null, - string? includeDomainsMode = null) + string? includeDomainsMode = null, + string? language = null, + bool? filterByLanguage = null, + bool? exactMatch = null, + bool? autoParameters = null) { this.Query = query ?? throw new ArgumentNullException(nameof(query)); this.Topic = topic; @@ -177,5 +205,9 @@ public TavilySearchRequest( this.EndDate = endDate; this.SafeSearch = safeSearch; this.IncludeDomainsMode = includeDomainsMode; + this.Language = language; + this.FilterByLanguage = filterByLanguage; + this.ExactMatch = exactMatch; + this.AutoParameters = autoParameters; } } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index 11cf4c06544c..436546a996fc 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -756,7 +756,11 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, FormatDate(this._searchOptions?.StartDate), FormatDate(this._searchOptions?.EndDate), this._searchOptions?.SafeSearch, - includeDomains is { Count: > 0 } ? ToApiIncludeDomainsMode(this._searchOptions?.IncludeDomainsMode) : null); + includeDomains is { Count: > 0 } ? ToApiIncludeDomainsMode(this._searchOptions?.IncludeDomainsMode) : null, + this._searchOptions?.Language, + this._searchOptions?.FilterByLanguage, + this._searchOptions?.ExactMatch, + this._searchOptions?.AutoParameters); } private static string? FormatDate(DateTime? date) => date?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs index 71beaaf138f8..a53f45976bcd 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs @@ -64,6 +64,36 @@ public sealed class TavilyTextSearchOptions /// public TavilyIncludeDomainsMode? IncludeDomainsMode { get; set; } + /// + /// Boost search results in a specific language. Accepts an ISO 639-1 code (for example "en" or "zh-cn") + /// or an English language name (for example "english"). By default this only boosts matching-language + /// results in ranking; set to strictly filter instead. Not sent when null. + /// + public string? Language { get; set; } + + /// + /// Strictly filter out results that do not match instead of only boosting them. + /// Tavily requires to be set and returns an error otherwise. + /// Sent when not null, including an explicit false. + /// + public bool? FilterByLanguage { get; set; } + + /// + /// Only return results containing the exact quoted phrase(s) in the query, bypassing synonyms or + /// semantic variations. Wrap the target phrases in quotes within the query. + /// Sent when not null, including an explicit false. + /// + public bool? ExactMatch { get; set; } + + /// + /// Let Tavily configure search parameters automatically from the query's content and intent; + /// explicitly set options still take precedence. , + /// and the number of results are never set automatically. Tavily may raise the search depth to advanced, + /// which uses 2 API credits per request; set to basic to avoid the extra cost. + /// Sent when not null, including an explicit false. + /// + public bool? AutoParameters { get; set; } + /// /// Include an LLM-generated answer to the provided query. /// basic or true returns a quick answer. advanced returns a more detailed answer. From 47b5a98a4caff337d99ecb8231119dc2cd911bc0 Mon Sep 17 00:00:00 2001 From: Mani Date: Tue, 27 Oct 2026 13:40:16 -0400 Subject: [PATCH 6/8] Tavily: document that Country is not supported with fast/ultra-fast depths Co-Authored-By: Claude Sonnet 5.5 --- .../src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs index a53f45976bcd..0ec24475f3c9 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs @@ -36,7 +36,8 @@ public sealed class TavilyTextSearchOptions /// /// Boost search results from a specific country, for example "united states". - /// Available only when the search topic is general. + /// Available only when the search topic is general. Not supported with the + /// or search depths, for which Tavily returns an error. /// public string? Country { get; set; } From b9cf563f23e28c7e5329a500195d385803ce7764 Mon Sep 17 00:00:00 2001 From: Mani Date: Tue, 27 Oct 2026 14:01:38 -0400 Subject: [PATCH 7/8] Tavily: accept image URL strings in search responses When image descriptions are not requested Tavily returns images as URL strings, which the url+description image model could not deserialize. A converter now reads either form; the object form keeps its required url and description. Co-Authored-By: Claude Sonnet 5.5 --- .../Web/Tavily/TavilyTextSearchTests.cs | 75 +++++++++++++++++++ .../Plugins.Web/Tavily/TavilyImageResult.cs | 1 + .../Tavily/TavilyImageResultConverter.cs | 66 ++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResultConverter.cs diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index 8a5c7d6883fb..59ebf5f3d793 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -402,6 +402,81 @@ public async Task IncludeImagesWithoutImagesInResponseDoesNotThrowAsync() Assert.Equal(4, pageList.Count); } + [Fact] + public async Task StringImageUrlsInResponseAreReturnedAsImageResultsAsync() + { + // Arrange - without image descriptions Tavily returns the images as URL strings + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node["images"] = new System.Text.Json.Nodes.JsonArray("https://example.com/a.png", "https://example.com/b.jpg"); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + var list = await result.Results.ToListAsync(); + + // Assert - the four web results are unchanged and the two images follow with the URL and an empty description + Assert.Equal(6, list.Count); + Assert.Equal("https://example.com/a.png", list[4].Link); + Assert.Equal("a.png", list[4].Name); + Assert.Equal(string.Empty, list[4].Value); + Assert.Equal("https://example.com/b.jpg", list[5].Link); + Assert.Equal(string.Empty, list[5].Value); + } + + [Fact] + public async Task ObjectImagesWithDescriptionsAreStillReadAsync() + { + // Arrange + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node["images"] = System.Text.Json.Nodes.JsonNode.Parse("[{\"url\":\"https://example.com/a.png\",\"description\":\"A diagram\"}]"); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true, IncludeImageDescriptions = true }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + var list = await result.Results.ToListAsync(); + + // Assert + Assert.Equal(5, list.Count); + Assert.Equal("https://example.com/a.png", list[4].Link); + Assert.Equal("A diagram", list[4].Value); + } + + [Fact] + public async Task ObjectImageWithoutDescriptionStillThrowsAsync() + { + // Arrange - the object form keeps its required properties + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node["images"] = System.Text.Json.Nodes.JsonNode.Parse("[{\"url\":\"https://example.com/a.png\"}]"); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act & Assert + await Assert.ThrowsAnyAsync(async () => await (await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 })).Results.ToListAsync()); + } + + [Fact] + public async Task PerResultImageStringArraysDoNotThrowAsync() + { + // Arrange - Tavily can also return an images string array on each result + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node.Remove("images"); + foreach (var item in node["results"]!.AsArray()) + { + item!.AsObject()["images"] = new System.Text.Json.Nodes.JsonArray("https://example.com/a.png"); + } + + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + Assert.Equal(4, (await result.Results.ToListAsync()).Count); + } + [Fact] public async Task IncludeImagesWithImagesInResponseAppendsImageResultsAsync() { diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResult.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResult.cs index 461f38a79d0d..6cc04079b295 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResult.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResult.cs @@ -9,6 +9,7 @@ namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; /// Represents an image result. /// #pragma warning disable CA1812 // Instantiated by reflection +[JsonConverter(typeof(TavilyImageResultConverter))] internal sealed class TavilyImageResult { /// diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResultConverter.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResultConverter.cs new file mode 100644 index 000000000000..60d4e367f278 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResultConverter.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; + +/// +/// Reads an image result from either a plain image URL string (returned when image descriptions are not +/// requested) or an object with url and description (returned when they are). +/// +internal sealed class TavilyImageResultConverter : JsonConverter +{ + /// + public override TavilyImageResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + return new TavilyImageResult(reader.GetString()!, string.Empty); + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("An image result must be a URL string or an object."); + } + + string? url = null; + string? description = null; + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + string? name = reader.GetString(); + reader.Read(); + if (name == "url") + { + url = reader.TokenType == JsonTokenType.Null ? null : reader.GetString(); + } + else if (name == "description") + { + description = reader.TokenType == JsonTokenType.Null ? null : reader.GetString(); + } + else + { + reader.Skip(); + } + } + + return new TavilyImageResult( + url ?? throw new JsonException("Required property 'url' was not found in the image result."), + description ?? throw new JsonException("Required property 'description' was not found in the image result.")); + } + + /// + public override void Write(Utf8JsonWriter writer, TavilyImageResult value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("url", value.Url); + writer.WriteString("description", value.Description); + writer.WriteEndObject(); + } +} From 99aeaf86d3138d1c43c4e4bfb4951a45595a7674 Mon Sep 17 00:00:00 2001 From: Mani Date: Tue, 27 Oct 2026 14:03:16 -0400 Subject: [PATCH 8/8] Tavily tests: assert JsonException for an image object missing its description Co-Authored-By: Claude Sonnet 5.5 --- .../Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index 59ebf5f3d793..2e177f17d1e8 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -453,7 +453,7 @@ public async Task ObjectImageWithoutDescriptionStillThrowsAsync() var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); // Act & Assert - await Assert.ThrowsAnyAsync(async () => await (await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 })).Results.ToListAsync()); + await Assert.ThrowsAsync(async () => await (await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 })).Results.ToListAsync()); } [Fact]