From 5c867a9b234b131fe9e375c253a3a560641aaaf9 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:15:43 +0200 Subject: [PATCH] Harden Confluence search and return its sources and let trusted providers continue chats that hold confidential tool results --- .../Assistants/I18N/allTexts.lua | 6 +++ .../Chat/ChatThreadExtensions.cs | 19 +++++--- .../Plugins/configuration/plugin.lua | 5 +- app/MindWork AI Studio/Program.cs | 2 +- .../ConfluenceSearchTool.cs | 46 +++++++++++++++++-- .../Tools/Web/WebPageAccessBlockReason.cs | 1 + .../Tools/Web/WebPageRetrievalOptions.cs | 15 ++++++ .../Tools/Web/WebPageRetrievalService.cs | 8 +++- .../wwwroot/changelog/v26.9.1.md | 9 ++-- documentation/Tools.md | 2 +- 10 files changed, 94 insertions(+), 19 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 2d72721f..ae7018b9 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -12562,6 +12562,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS: -- The setting '{0}' must not exceed {1}. UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::CONFLUENCESEARCHTOOL::T3109652601"] = "The setting '{0}' must not exceed {1}." +-- Confluence asked for a sign-in instead of showing search results. Your operating system's sign-in was not accepted by the wiki; open it in your browser to check your access. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::CONFLUENCESEARCHTOOL::T3122873647"] = "Confluence asked for a sign-in instead of showing search results. Your operating system's sign-in was not accepted by the wiki; open it in your browser to check your access." + -- Confluence Base URL UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::CONFLUENCESEARCHTOOL::T3278636117"] = "Confluence Base URL" @@ -12583,6 +12586,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS: -- Search Confluence UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::CONFLUENCESEARCHTOOL::T665149329"] = "Search Confluence" +-- Confluence search for “{0}” +UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::CONFLUENCESEARCHTOOL::T718586991"] = "Confluence search for “{0}”" + -- The HTTPS address of your Confluence site, including its path if present, such as https://wiki.example.org/confluence/. AI Studio searches through the same page reader used by Read Web Page. UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::CONFLUENCESEARCHTOOL::T859229067"] = "The HTTPS address of your Confluence site, including its path if present, such as https://wiki.example.org/confluence/. AI Studio searches through the same page reader used by Read Web Page." diff --git a/app/MindWork AI Studio/Chat/ChatThreadExtensions.cs b/app/MindWork AI Studio/Chat/ChatThreadExtensions.cs index 4f9612ed..3d55c29d 100644 --- a/app/MindWork AI Studio/Chat/ChatThreadExtensions.cs +++ b/app/MindWork AI Studio/Chat/ChatThreadExtensions.cs @@ -37,13 +37,20 @@ public static class ChatThreadExtensions }; // - // The confidence axis is checked on its own: a provider trusted by configuration counts as - // self-hosted for data-source security, which is the check further down, but that trust - // says nothing about how confidential the provider is. An organization which wants its - // contractually covered cloud provider to pass here raises its level through the custom - // confidence scheme instead. + // A provider trusted by the organization's configuration may continue the thread whatever + // confidence it requires, the same as the tools which put that data into the thread treat + // it as equal to a High-confidence provider. Otherwise such a provider could run a tool + // and then be locked out of its own chat by the result. // - if (providerConfidence < chatThread.RequiredProviderConfidence) + var isTrustedByConfiguration = provider switch + { + IProvider p => p.IsTrustedByConfiguration(settingsManager), + AIStudio.Settings.Provider p => p.IsTrustedByConfiguration(settingsManager), + + _ => false, + }; + + if (providerConfidence < chatThread.RequiredProviderConfidence && !isTrustedByConfiguration) return false; // The chat thread is available, but the data security is not specified. diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index ae23e7fe..97796c8b 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -742,6 +742,8 @@ CONFIG["SETTINGS"] = {} -- Tool IDs include: web_search, read_web_page, search_confluence -- Allowed values are: NONE, UNTRUSTED, VERY_LOW, LOW, MODERATE, MEDIUM, HIGH -- Defaults: web_search = VERY_LOW, read_web_page = VERY_LOW, search_confluence = VERY_LOW +-- search_confluence also always requires a HIGH-confidence provider or one trusted by the +-- organization, whatever value is set here. -- CONFIG["SETTINGS"]["DataTools.MinimumProviderConfidenceByToolId"] = { -- ["web_search"] = "VERY_LOW", -- ["read_web_page"] = "VERY_LOW", @@ -823,7 +825,8 @@ CONFIG["SETTINGS"] = {} -- baseUrl Required HTTPS root URL of the Confluence site, including its context path -- if present, for example https://wiki.example.org/confluence/. Search loads -- dosearchsite.action with the same web-page reader as read_web_page and uses --- the current user's operating-system sign-in. A provider must have HIGH +-- the current user's operating-system sign-in, also when the wiki has public +-- addresses. Redirects outside this URL are refused. A provider must have HIGH -- confidence or be trusted by the organization to receive search results. -- timeoutSeconds Search request timeout in seconds, at most 120. Default: 30. -- To read a found page, also configure read_web_page.allowedPrivateHosts if your wiki has a diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index e5e0b6a3..54c1608b 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -354,4 +354,4 @@ internal sealed class Program PluginFactory.Dispose(); programLogger.LogInformation("The AI Studio server was stopped."); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/ConfluenceSearchTool.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/ConfluenceSearchTool.cs index 99f0eda2..d6c4daab 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/ConfluenceSearchTool.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/ConfluenceSearchTool.cs @@ -28,7 +28,8 @@ public sealed class ConfluenceSearchTool(WebPageRetrievalService webPageRetrieva { Id = ToolSelectionRules.SEARCH_CONFLUENCE_TOOL_ID, ImplementationKey = ToolSelectionRules.SEARCH_CONFLUENCE_TOOL_ID, - // The runtime check also permits organization-trusted providers below HIGH. + // Kept low so that providers trusted by the organization below HIGH are offered the tool. + // The runtime check in ExecuteAsync requires HIGH confidence or that trust. MinimumProviderConfidence = ConfidenceLevel.VERY_LOW, SettingsSchema = ToolSettingsSchemaBuilder.Create() .Required(BASE_URL_SETTING) @@ -94,6 +95,11 @@ public sealed class ConfluenceSearchTool(WebPageRetrievalService webPageRetrieva public async Task ExecuteAsync(JsonElement arguments, ToolExecutionContext context, CancellationToken token = default) { + // + // A provider trusted by the organization's configuration counts as much as a High-confidence + // one. The chat thread's own check does the same, so such a provider may also continue the + // chat after the result raised its required confidence to HIGH. + // if (context.ProviderConfidence < ConfidenceLevel.HIGH && !context.ProviderIsTrustedByConfiguration) throw new ToolExecutionBlockedException(TB("Searching the company wiki requires a High-confidence provider or one trusted by your organization's configuration.")); @@ -132,19 +138,33 @@ public sealed class ConfluenceSearchTool(WebPageRetrievalService webPageRetrieva ProviderConfidence = context.ProviderConfidence, ProviderIsTrustedByConfiguration = context.ProviderIsTrustedByConfiguration, UseOsSso = true, - IsPrivateHostAllowed = host => host.Equals(baseUrl!.Host, StringComparison.OrdinalIgnoreCase), + IsPrivateHostAllowed = host => IsWikiHost(baseUrl!, host), + + // The wiki address comes from the user or the organization, never from the model, + // so the sign-in may also go to a wiki with public addresses: + IsOsSsoAllowedForPublicHost = host => IsWikiHost(baseUrl!, host), + + // Checked before every redirect is followed, so the query never reaches a host + // outside the wiki: + IsTargetAllowed = target => IsWithinWiki(baseUrl!, target), }, token); } + catch (WebPageAccessBlockedException exception) when (exception.Reason is WebPageAccessBlockReason.TARGET_NOT_ALLOWED) + { + throw new ToolExecutionBlockedException(TB("Confluence redirected the search outside the configured wiki.")); + } catch (WebPageAccessBlockedException exception) { throw new ToolExecutionBlockedException(exception.Message); } var page = retrievedPage.Page; - if (page.FinalUrl.Scheme != baseUrl!.Scheme || page.FinalUrl.Host != baseUrl.Host || page.FinalUrl.Port != baseUrl.Port || - !page.FinalUrl.AbsolutePath.StartsWith(baseUrl.AbsolutePath, StringComparison.Ordinal)) + if (!IsWithinWiki(baseUrl!, page.FinalUrl)) throw new InvalidOperationException(TB("Confluence redirected the search outside the configured wiki.")); + if (IsLoginPage(page.FinalUrl)) + throw new InvalidOperationException(TB("Confluence asked for a sign-in instead of showing search results. Your operating system's sign-in was not accepted by the wiki; open it in your browser to check your access.")); + var markdown = retrievedPage.ExtractedPage.Markdown; if (string.IsNullOrWhiteSpace(markdown)) throw new InvalidOperationException(TB("Confluence returned a search page without readable results.")); @@ -165,10 +185,28 @@ public sealed class ConfluenceSearchTool(WebPageRetrievalService webPageRetrieva ["title"] = modelContent.Title, ["text_content"] = modelContent.Markdown, }, + + // The search page is what AI Studio actually read. Pages found on it become sources + // once read_web_page loads them: + Sources = [new Source(string.Format(TB("Confluence search for “{0}”"), query), page.FinalUrl.ToString(), SourceOrigin.TOOL)], RequiredProviderConfidence = ConfidenceLevel.HIGH, }; } + private static bool IsWikiHost(Uri baseUrl, string host) => WebHostHelper.Normalize(host) == WebHostHelper.Normalize(baseUrl.Host); + + internal static bool IsWithinWiki(Uri baseUrl, Uri url) => + url.Scheme == baseUrl.Scheme && + IsWikiHost(baseUrl, url.Host) && + url.Port == baseUrl.Port && + url.AbsolutePath.StartsWith(baseUrl.AbsolutePath, StringComparison.Ordinal); + + // Confluence answers a request without a valid session with its login page, which would + // otherwise reach the model as a search without results: + internal static bool IsLoginPage(Uri url) => + url.AbsolutePath.EndsWith("/login.action", StringComparison.OrdinalIgnoreCase) || + url.Query.Contains("os_destination=", StringComparison.OrdinalIgnoreCase); + internal static bool TryParseBaseUrl(string? value, out Uri? baseUrl) { baseUrl = null; diff --git a/app/MindWork AI Studio/Tools/Web/WebPageAccessBlockReason.cs b/app/MindWork AI Studio/Tools/Web/WebPageAccessBlockReason.cs index 652001d2..803689aa 100644 --- a/app/MindWork AI Studio/Tools/Web/WebPageAccessBlockReason.cs +++ b/app/MindWork AI Studio/Tools/Web/WebPageAccessBlockReason.cs @@ -8,4 +8,5 @@ public enum WebPageAccessBlockReason NEVER_ALLOWED_ADDRESS, PRIVATE_HOST_NOT_ALLOWED, INSUFFICIENT_PROVIDER_CONFIDENCE, + TARGET_NOT_ALLOWED, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Web/WebPageRetrievalOptions.cs b/app/MindWork AI Studio/Tools/Web/WebPageRetrievalOptions.cs index b7c54f67..b8f14545 100644 --- a/app/MindWork AI Studio/Tools/Web/WebPageRetrievalOptions.cs +++ b/app/MindWork AI Studio/Tools/Web/WebPageRetrievalOptions.cs @@ -30,5 +30,20 @@ public sealed class WebPageRetrievalOptions public Func? IsPrivateHostAllowed { get; init; } + /// + /// Decides for every URL, the first one as well as each redirect target, whether it may be + /// requested at all. It runs before anything is sent, so a refused target never sees the URL. + /// + public Func? IsTargetAllowed { get; init; } + + /// + /// Allows the operating system's sign-in for a host which resolves to public addresses. + /// + /// + /// Without it, the sign-in is only sent to allowed private hosts. Use it only for a host + /// which the user or the organization configured, never for one a model named. + /// + public Func? IsOsSsoAllowedForPublicHost { get; init; } + public Func? OnPrivateHostProviderBlockAsync { get; init; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Web/WebPageRetrievalService.cs b/app/MindWork AI Studio/Tools/Web/WebPageRetrievalService.cs index 79b1e507..c8c4dc5f 100644 --- a/app/MindWork AI Studio/Tools/Web/WebPageRetrievalService.cs +++ b/app/MindWork AI Studio/Tools/Web/WebPageRetrievalService.cs @@ -92,6 +92,9 @@ public sealed class WebPageRetrievalService(HTMLParser htmlParser) if (url is not { Scheme: "http" or "https" }) throw new WebPageAccessBlockedException("Only HTTP and HTTPS URLs are supported.", WebPageAccessBlockReason.UNSUPPORTED_SCHEME); + if (options.IsTargetAllowed?.Invoke(url) is false) + throw new WebPageAccessBlockedException($"The web page '{url.GetLeftPart(UriPartial.Path)}' is outside the targets this request may reach.", WebPageAccessBlockReason.TARGET_NOT_ALLOWED); + if (!options.TargetChosenByUser && IsBlockedHostName(url.Host)) throw new WebPageAccessBlockedException("Local web page URLs are not supported.", WebPageAccessBlockReason.LOCAL_HOST_NAME); @@ -158,9 +161,10 @@ public sealed class WebPageRetrievalService(HTMLParser htmlParser) originalUrl.Host.Equals(candidateUrl.Host, StringComparison.OrdinalIgnoreCase) && originalUrl.Port == candidateUrl.Port && !IsBlockedHostName(candidateUrl.Host) && - options.IsPrivateHostAllowed?.Invoke(candidateUrl.Host) is true && addresses.Count > 0 && - addresses.All(IsNonPublicAddress); + (addresses.All(IsNonPublicAddress) + ? options.IsPrivateHostAllowed?.Invoke(candidateUrl.Host) is true + : options.IsOsSsoAllowedForPublicHost?.Invoke(candidateUrl.Host) is true); private static IPAddress NormalizeAddress(IPAddress address) => address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md index 03578ef2..5b84c7de 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -1,5 +1,7 @@ # v26.9.1, build 256 (2026-09-xx xx:xx UTC) -- Added a Confluence search tool for your company's wiki. Set its wiki address in the tool settings, then select it alongside Read Web Page so the AI can find relevant pages and open them. Search uses your Windows sign-in and is available only with a provider trusted to receive your company's content. +- Added a Search Confluence tool for your company's wiki. Set your wiki's address in the tool settings, then select the tool alongside Read Web Page so the AI can find relevant pages and open them. The Confluence logo shows you the tool in tool lists and in your chat's tool activity. +- Added sign-in with your operating system account to the Search Confluence tool, so you do not have to enter a password. When your wiki does not accept that sign-in, AI Studio tells you so instead of reporting an empty search. +- Added safeguards to the Search Confluence tool: it works only with a High-confidence provider or one your organization trusts, and it never follows a redirect that leads away from your wiki. Each search appears in the sources of the answer. - Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Schütt (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature. - Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end. - Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings. @@ -35,7 +37,6 @@ - Added an optional API key to every server you host yourself, among them LM Studio, llama.cpp, and whisper.cpp. Such a server may ask for one itself or sit behind a login your organization placed in front of it. So far, only Ollama and vLLM could be given a key. - Added a setting for the audio quality used when your speech and your audio and video files are transcribed. AI Studio prepares every recording before it goes to your transcription provider, and you now decide how much detail it keeps: a lower quality travels faster, a higher one gives the transcription model more to work with. You find it in the app settings, right below your transcription provider. Thanks, Dominic Neuburg (`donework`), for this contribution. - Added organization-wide management for the audio quality used when transcribing. IT departments can set the quality their organization works with and lock it, or leave it as a default their colleagues are free to change. -- Improved the Search Confluence tool with the Confluence logo, so you can recognize it in tool lists and in your chat's tool activity. The Information page identifies the logo's owner. - Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty. - Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading. - Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you. @@ -50,8 +51,8 @@ - Improved what happens when you ask for web content to be cleaned up and no model is available for it. AI Studio loads the page and tells you it arrived uncleaned, instead of quietly handing you the raw page with its navigation and advertising still in it. - Changed what a tile that opens a chat directly starts with: when it uses a chat template that brings its own tools or data sources, that template decides them. The Assistant Builder says so while you build such a tile. - Changed which model a security check of an assistant plugin may fall back on. When you have set none aside for these checks, AI Studio uses the model you were working with, but only when that model meets the trust your organization requires for a check. One ruled out for that purpose no longer gets to read a plugin's code. -- Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet. -- Fixed Confluence search failing with a 401 error on wikis that reject the REST search request. Search now uses the wiki's normal search page and the same sign-in path as Read Web Page. +- Changed how provider trust and provider confidence work together for local data sources. Marking a provider as trustworthy in a configuration no longer also satisfies the confidence level a data source requires: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. +- Changed which providers may continue a chat that holds confidential tool results, for example a page from your intranet or a search in your company wiki. A provider your organization marked as trustworthy in its configuration may continue such a chat, just as it may use the tools which brought those results in. - Fixed the abilities AI Studio assumed for many models. We checked the families against their documentation: some models gained image input, reasoning, or tool calling, others lost an ability they never had. - Fixed the Document Analysis assistant freezing while you edited a policy. It needed a change of yours to be saved in the background just as you were making the next one — picking a provider, for instance — which is why it hit some of you again and again and others never at all. - Fixed a renamed policy losing its new name in the Document Analysis assistant. The name was kept only when you happened to change something else afterward. diff --git a/documentation/Tools.md b/documentation/Tools.md index 6a85b416..103cd6b9 100644 --- a/documentation/Tools.md +++ b/documentation/Tools.md @@ -92,7 +92,7 @@ What differs between callers is which targets are acceptable, and that follows f `read_web_page` remains the independent single-URL tool and may use its configured private-host allowlist and operating-system sign-in behavior for allowed HTTPS targets. An allowed private host can only be read by a High-confidence provider or a provider instance listed in `DataSourceSecuritySettings.TrustedProviderIds`. -`search_confluence` builds a CQL query for the configured HTTPS Confluence Data Center site's `dosearchsite.action` page and loads it through `WebPageRetrievalService`, the same reader used by `read_web_page`. The model supplies a search phrase and optionally a space key, never a URL or CQL expression. The tool returns the extracted search page as Markdown with links, after truncation and prompt-injection filtering. It only permits the configured private host and requires a High-confidence or organization-trusted provider; the result raises the chat's continuing confidence requirement to High. Select `read_web_page` as well to load a result's full content; its private-host allowlist still applies to that page. +`search_confluence` builds a CQL query for the configured HTTPS Confluence Data Center site's `dosearchsite.action` page and loads it through `WebPageRetrievalService`, the same reader used by `read_web_page`. The model supplies a search phrase and optionally a space key, never a URL or CQL expression. The tool returns the extracted search page as Markdown with links, after truncation and prompt-injection filtering, and lists the search page as its source. Every request, redirects included, must stay within the configured base URL; `WebPageRetrievalOptions.IsTargetAllowed` refuses a redirect before it is followed, so the query never reaches another host. The operating-system sign-in goes to the configured host even when it has public addresses (`IsOsSsoAllowedForPublicHost`), and a redirect to Confluence's login page is reported as a failed sign-in instead of an empty search. The tool treats a provider trusted by the organization the same as a High-confidence one, and the result raises the chat's continuing confidence requirement to High. `ChatThreadExtensions.IsLLMProviderAllowed` lets a trusted provider meet that requirement as well, so it can continue the chat after a search. Select `read_web_page` as well to load a result's full content; its private-host allowlist still applies to that page. Every successfully retrieved page with readable content is also returned as a structured tool source, using the final URL after redirects and the extracted page title. The provider collects these sources across local tool calls and attaches them to the final response under the separate “Sources used by tools” heading. Failed, blocked, empty, and duplicate retrievals do not add sources — a pattern worth copying for any tool that returns material the user may want to check.