From e3744889bf79a4e73eee284e4396dc960d499015 Mon Sep 17 00:00:00 2001 From: Rockford lhotka Date: Sat, 11 Jul 2026 22:54:45 -0500 Subject: [PATCH] web_search: return an error when the API key is unconfigured Previously BraveSearchProvider returned an empty result list when no API key was set, which WebSearchToolExecutor reported as "No results found." with IsError=false. That's indistinguishable from a search that genuinely found nothing, so the agent couldn't tell the user that web search is simply not configured. Introduce WebSearchNotConfiguredException; the provider throws it (with an actionable message naming WebTools:ApiKey / the env var) instead of returning empty, and the executor catches it to return IsError=true with that message. Distinct from both the generic "Search failed" path and "No results found". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Brave/BraveSearchProvider.cs | 6 +++++- .../WebSearchNotConfiguredException.cs | 8 ++++++++ src/RockBot.Tools.Web/WebSearchToolExecutor.cs | 7 +++++++ .../BraveSearchProviderTests.cs | 10 ++++++---- .../WebSearchToolExecutorTests.cs | 16 ++++++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/RockBot.Tools.Web/WebSearchNotConfiguredException.cs diff --git a/src/RockBot.Tools.Web/Brave/BraveSearchProvider.cs b/src/RockBot.Tools.Web/Brave/BraveSearchProvider.cs index 2a90b16b..0ff450c2 100644 --- a/src/RockBot.Tools.Web/Brave/BraveSearchProvider.cs +++ b/src/RockBot.Tools.Web/Brave/BraveSearchProvider.cs @@ -17,7 +17,11 @@ public async Task> SearchAsync(string query, int if (string.IsNullOrEmpty(apiKey)) { logger.LogWarning("Brave API key not set. Configure WebTools:ApiKey in user secrets or set the {EnvVar} environment variable", options.ApiKeyEnvVar); - return []; + // Signal a configuration gap rather than returning an empty result set, which the + // caller can't distinguish from a search that genuinely found nothing. + throw new WebSearchNotConfiguredException( + $"Web search is unavailable because the Brave Search API key is not configured. " + + $"Set WebTools:ApiKey (config / user secrets) or the {options.ApiKeyEnvVar} environment variable."); } using var client = httpClientFactory.CreateClient("RockBot.Tools.Web.Brave"); diff --git a/src/RockBot.Tools.Web/WebSearchNotConfiguredException.cs b/src/RockBot.Tools.Web/WebSearchNotConfiguredException.cs new file mode 100644 index 00000000..68a67889 --- /dev/null +++ b/src/RockBot.Tools.Web/WebSearchNotConfiguredException.cs @@ -0,0 +1,8 @@ +namespace RockBot.Tools.Web; + +/// +/// Thrown by an when web search cannot run because its API +/// credentials are not configured. Lets callers distinguish a configuration gap — which should +/// be surfaced to the user as an actionable error — from a search that ran and found nothing. +/// +public sealed class WebSearchNotConfiguredException(string message) : InvalidOperationException(message); diff --git a/src/RockBot.Tools.Web/WebSearchToolExecutor.cs b/src/RockBot.Tools.Web/WebSearchToolExecutor.cs index 1dc7b257..9055e707 100644 --- a/src/RockBot.Tools.Web/WebSearchToolExecutor.cs +++ b/src/RockBot.Tools.Web/WebSearchToolExecutor.cs @@ -62,6 +62,13 @@ public async Task ExecuteAsync(ToolInvokeRequest request, Ca IsError = false }; } + catch (WebSearchNotConfiguredException ex) + { + // Configuration gap (e.g. missing API key). Surface the actionable message + // directly so the agent can tell the user web search isn't set up, rather than + // reporting a generic search failure or a misleading "no results found". + return Error(request, ex.Message); + } catch (Exception ex) { return Error(request, $"Search failed: {ex.Message}"); diff --git a/tests/RockBot.Tools.Web.Tests/BraveSearchProviderTests.cs b/tests/RockBot.Tools.Web.Tests/BraveSearchProviderTests.cs index 4b2bec37..b80c9a76 100644 --- a/tests/RockBot.Tools.Web.Tests/BraveSearchProviderTests.cs +++ b/tests/RockBot.Tools.Web.Tests/BraveSearchProviderTests.cs @@ -82,7 +82,7 @@ public async Task SearchAsync_DeserializesResultsCorrectly() } [TestMethod] - public async Task SearchAsync_ReturnsEmpty_WhenApiKeyMissing() + public async Task SearchAsync_Throws_WhenApiKeyMissing() { var handler = new CaptureHandler(new HttpResponseMessage(HttpStatusCode.OK) { @@ -91,9 +91,11 @@ public async Task SearchAsync_ReturnsEmpty_WhenApiKeyMissing() var options = new WebToolOptions { ApiKeyEnvVar = "ROCKBOT_TEST_BRAVE_KEY_NONEXISTENT_12345" }; var provider = new BraveSearchProvider(new StubFactory(handler), options, NullLogger.Instance); - var results = await provider.SearchAsync("test", 10, CancellationToken.None); - - Assert.AreEqual(0, results.Count); + // A missing key is a configuration gap, not a search that returned nothing — the + // provider throws so the caller can surface an actionable error to the user. + var ex = await Assert.ThrowsExactlyAsync( + () => provider.SearchAsync("test", 10, CancellationToken.None)); + StringAssert.Contains(ex.Message, "not configured"); } [TestMethod] diff --git a/tests/RockBot.Tools.Web.Tests/WebSearchToolExecutorTests.cs b/tests/RockBot.Tools.Web.Tests/WebSearchToolExecutorTests.cs index 9bb56709..e9376329 100644 --- a/tests/RockBot.Tools.Web.Tests/WebSearchToolExecutorTests.cs +++ b/tests/RockBot.Tools.Web.Tests/WebSearchToolExecutorTests.cs @@ -53,6 +53,22 @@ public async Task ExecuteAsync_ReturnsError_WhenProviderThrows() StringAssert.Contains(response.Content, "Search failed"); } + [TestMethod] + public async Task ExecuteAsync_ReturnsError_WhenSearchNotConfigured() + { + var executor = new WebSearchToolExecutor( + new ThrowingSearchProvider(new WebSearchNotConfiguredException( + "Web search is unavailable because the Brave Search API key is not configured.")), + new WebToolOptions()); + + var response = await executor.ExecuteAsync(MakeRequest("""{"query": "test"}"""), CancellationToken.None); + + Assert.IsTrue(response.IsError); + StringAssert.Contains(response.Content, "not configured"); + // Distinct from the generic "Search failed" path so the agent can act on it. + StringAssert.Contains(response.Content, "Brave Search API key"); + } + [TestMethod] public async Task ExecuteAsync_ReturnsError_WhenQueryMissing() {