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/Components/Settings/SettingsPanelTools.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelTools.razor.cs index b6bd8fa5..6aa9a284 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTools.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTools.razor.cs @@ -74,8 +74,12 @@ public partial class SettingsPanelTools : SettingsPanelBase ? this.T("No minimum confidence level chosen") : confidenceLevel.GetName(); - private string SetCurrentConfidenceLevelColorStyle(ToolCatalogItem item) => - $"background-color: {GetMinimumProviderConfidence(item).GetColor(this.SettingsManager)};"; + private string SetCurrentConfidenceLevelColorStyle(ToolCatalogItem item) + { + // Outlook Mail always enforces High, whatever level is stored for it: + var confidenceLevel = item.Definition.Id == ToolSelectionRules.OUTLOOK_MAIL_TOOL_ID ? ConfidenceLevel.HIGH : GetMinimumProviderConfidence(item); + return $"background-color: {confidenceLevel.GetColor(this.SettingsManager)};"; + } private bool IsToolConfidenceManaged(ToolCatalogItem item) => item.Definition.Id == ToolSelectionRules.OUTLOOK_MAIL_TOOL_ID || diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index c843388e..f2cc9e88 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -827,7 +827,8 @@ CONFIG["SETTINGS"] = {} -- The URL cannot contain credentials, a query, or a fragment. -- Outlook Mail searches only the employee's primary mailbox. It requires a HIGH-confidence -- provider or one trusted by the organization, even if a user lowers the tool's generic --- minimum-confidence setting. Reading mail marks the chat as requiring HIGH confidence. +-- minimum-confidence setting. Reading mail marks the chat as requiring HIGH confidence; a +-- provider trusted by the organization may still continue such a chat. -- -- CONFIG["SETTINGS"]["DataTools.LockedToolSettings"] = { -- ["web_search.searxng.baseUrl"] = "https://searxng.example.org/", diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/OutlookMail/EwsMailClient.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/OutlookMail/EwsMailClient.cs index de0b2119..8164eda6 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/OutlookMail/EwsMailClient.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/OutlookMail/EwsMailClient.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Text; using System.Xml; @@ -13,6 +14,7 @@ internal sealed class EwsMailClient : IDisposable private const int MAX_RESPONSE_BYTES = 2_000_000; private const int MAX_FOLDERS = 250; private const int FOLDER_PAGE_SIZE = 100; + private const int MAX_PARALLEL_FOLDER_SEARCHES = 4; internal const int MAX_RESULTS = 20; internal const int MAX_BODY_CHARACTERS = 20_000; @@ -50,61 +52,88 @@ internal sealed class EwsMailClient : IDisposable using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token); deadline.CancelAfter(TimeSpan.FromSeconds(90)); var (folders, foldersPartial) = await this.FindFoldersAsync(deadline.Token); + + // + // Recent mail is most often the mail the user means. Every folder is therefore asked for + // its newest matches, and the newest of all of them win. Stopping at the first folders + // with enough hits would return old mail from an archive and leave out yesterday's + // message in the inbox. + // + var resultLock = new Lock(); var messages = new List(); var partial = foldersPartial; var searchedFolders = false; - foreach (var folder in folders) + try { - if (messages.Count == MAX_RESULTS) + var options = new ParallelOptions { MaxDegreeOfParallelism = MAX_PARALLEL_FOLDER_SEARCHES, CancellationToken = deadline.Token }; + await Parallel.ForEachAsync(folders, options, async (folder, folderToken) => { - partial = true; - break; - } - - try - { - var root = ResponseRoot(await this.SendAsync(BuildFindItem(folder, terms, MAX_RESULTS), deadline.Token), "FindItem"); - var items = root.Element(T + "Items") ?? throw new EwsMailException("Exchange returned an incomplete search response."); - searchedFolders = true; - var added = 0; - foreach (var item in items.Elements(T + "Message")) + var folderResult = await this.SearchFolderAsync(folder, terms, folderToken); + lock (resultLock) { - if (RequiredId(item, "ParentFolderId") != folder) - { - partial = true; - continue; - } - var id = RequiredId(item, "ItemId"); - var subject = Truncate(item.Element(T + "Subject")?.Value ?? string.Empty, 300); - var mailbox = item.Element(T + "From")?.Element(T + "Mailbox"); - var sender = Truncate(mailbox?.Element(T + "EmailAddress")?.Value ?? mailbox?.Element(T + "Name")?.Value ?? string.Empty, 320); - var date = Truncate(item.Element(T + "DateTimeReceived")?.Value ?? item.Element(T + "DateTimeSent")?.Value ?? string.Empty, 64); - var excerpt = item.Element(T + "Preview")?.Value ?? item.Element(T + "Body")?.Value ?? string.Empty; - var webPath = item.Element(T + "WebClientReadFormQueryString")?.Value; - messages.Add(new EwsMessage(id, subject, sender, date, Truncate(excerpt, 500), webPath?.Length <= 2048 ? webPath : null)); - added++; - if (messages.Count == MAX_RESULTS) - break; + messages.AddRange(folderResult.Messages); + partial |= folderResult.Partial; + searchedFolders |= folderResult.Searched; } - - if (root.Attribute("IncludesLastItemInRange")?.Value != "true" || items.Elements(T + "Message").Count() > added) - partial = true; - } - catch (EwsMailException) - { - partial = true; - } - catch (OperationCanceledException) when (!token.IsCancellationRequested) - { - partial = true; - break; - } + }); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + partial = true; } if (!searchedFolders && folders.Count > 0) throw new EwsMailException("Exchange could not search the primary mailbox. Check the VPN connection and EWS access."); - return new EwsSearchResult(messages, partial); + if (messages.Count > MAX_RESULTS) + partial = true; + + var newest = messages.OrderByDescending(message => message.ReceivedAt).Take(MAX_RESULTS).ToList(); + return new EwsSearchResult(newest, partial); + } + + private async Task<(List Messages, bool Partial, bool Searched)> SearchFolderAsync(string folder, string terms, CancellationToken token) + { + try + { + var root = ResponseRoot(await this.SendAsync(BuildFindItem(folder, terms, MAX_RESULTS), token), "FindItem"); + var items = root.Element(T + "Items") ?? throw new EwsMailException("Exchange returned an incomplete search response."); + var partial = root.Attribute("IncludesLastItemInRange")?.Value != "true"; + var returned = items.Elements(T + "Message").ToList(); + if (returned.Count > MAX_RESULTS) + partial = true; + + var messages = new List(); + foreach (var item in returned.Take(MAX_RESULTS)) + { + if (RequiredId(item, "ParentFolderId") != folder) + { + partial = true; + continue; + } + + var id = RequiredId(item, "ItemId"); + var subject = Truncate(item.Element(T + "Subject")?.Value ?? string.Empty, 300); + var mailbox = item.Element(T + "From")?.Element(T + "Mailbox"); + var sender = Truncate(mailbox?.Element(T + "EmailAddress")?.Value ?? mailbox?.Element(T + "Name")?.Value ?? string.Empty, 320); + var rawDate = item.Element(T + "DateTimeReceived")?.Value ?? item.Element(T + "DateTimeSent")?.Value ?? string.Empty; + var receivedAt = DateTimeOffset.TryParse(rawDate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedDate) ? parsedDate : DateTimeOffset.MinValue; + var excerpt = item.Element(T + "Preview")?.Value ?? item.Element(T + "Body")?.Value ?? string.Empty; + var webPath = item.Element(T + "WebClientReadFormQueryString")?.Value; + messages.Add(new EwsMessage(id, subject, sender, Truncate(rawDate, 64), Truncate(excerpt, 500), webPath?.Length <= 2048 ? webPath : null, receivedAt)); + } + + return (messages, partial, true); + } + catch (EwsMailException) + { + return ([], true, false); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + // The per-request timeout of the HTTP client; the other folders are still searched. + return ([], true, false); + } } internal async Task ReadAsync(string itemId, CancellationToken token) @@ -250,8 +279,10 @@ internal sealed class EwsMailClient : IDisposable new XElement(M + "ItemShape", new XElement(T + "BaseShape", "IdOnly"), Properties("item:ParentFolderId", "item:Subject", "item:DateTimeReceived", "item:DateTimeSent", "item:Preview", "message:From", "item:WebClientReadFormQueryString")), new XElement(M + "IndexedPageItemView", new XAttribute("MaxEntriesReturned", pageSize), new XAttribute("Offset", 0), new XAttribute("BasePoint", "Beginning")), - new XElement(M + "QueryString", terms), - new XElement(M + "ParentFolderIds", new XElement(T + "FolderId", new XAttribute("Id", folderId))))); + new XElement(M + "SortOrder", new XElement(T + "FieldOrder", new XAttribute("Order", "Descending"), new XElement(T + "FieldURI", new XAttribute("FieldURI", "item:DateTimeReceived")))), + // The EWS schema requires this order: SortOrder, then ParentFolderIds, then QueryString. + new XElement(M + "ParentFolderIds", new XElement(T + "FolderId", new XAttribute("Id", folderId))), + new XElement(M + "QueryString", terms))); internal static XDocument BuildGetItem(string itemId) => Envelope(new XElement(M + "GetItem", new XElement(M + "ItemShape", new XElement(T + "BaseShape", "IdOnly"), new XElement(T + "BodyType", "Text"), @@ -270,7 +301,7 @@ internal sealed class EwsMailClient : IDisposable public void Dispose() => this.client.Dispose(); } -internal sealed record EwsMessage(string Id, string Subject, string Sender, string Date, string Excerpt, string? WebPath); +internal sealed record EwsMessage(string Id, string Subject, string Sender, string Date, string Excerpt, string? WebPath, DateTimeOffset ReceivedAt); internal sealed record EwsSearchResult(IReadOnlyList Messages, bool Partial); internal sealed record EwsReadResult(string Subject, string Body, bool Truncated, string? WebPath); internal sealed class EwsMailException(string message) : Exception(message); diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/OutlookMail/OutlookMailTool.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/OutlookMail/OutlookMailTool.cs index 3cd51ac3..8b32ff8a 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/OutlookMail/OutlookMailTool.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/OutlookMail/OutlookMailTool.cs @@ -40,7 +40,7 @@ public sealed class OutlookMailTool(PromptInjectionGuardService promptInjectionG Function = new() { Name = ToolSelectionRules.OUTLOOK_MAIL_TOOL_ID, - DescriptionForLLM = "Search the signed-in employee's primary Outlook mailbox or read one message from a previous search. Works through company Exchange without opening Outlook.", + DescriptionForLLM = "Search the signed-in employee's primary Outlook mailbox or read one message from a previous search. Search results are the newest matches first. Works through company Exchange without opening Outlook.", Parameters = ToolParameterSchemaBuilder.Create() .RequiredEnum(OPERATION_ARGUMENT, "Search mail or read a message from a previous result.", "search", "read") .OptionalString(TERMS_ARGUMENT, "Plain search terms, required for search.") @@ -179,8 +179,10 @@ public sealed class OutlookMailTool(PromptInjectionGuardService promptInjectionG { foreach (var key in this.cachedIds.Where(entry => entry.Value.ExpiresAt <= DateTimeOffset.UtcNow).Select(entry => entry.Key).ToList()) this.cachedIds.Remove(key); + // Every ID lives equally long, so the earliest expiry is the oldest one. The order of + // the dictionary's keys says nothing about that once entries were removed. if (this.cachedIds.Count >= MAX_CACHED_IDS) - this.cachedIds.Remove(this.cachedIds.Keys.First()); + this.cachedIds.Remove(this.cachedIds.MinBy(entry => entry.Value.ExpiresAt).Key); var id = Guid.NewGuid().ToString("N"); this.cachedIds[id] = new CachedId(ewsId, endpoint.AbsoluteUri, DateTimeOffset.UtcNow.AddMinutes(15)); return id; 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 ab09b451..7c0d90d1 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -2,8 +2,9 @@ - Added a way to copy an entire chat, either with the button in the chat toolbar or next to the chat in the chat list. The copy opens right away so you can continue in it, while the original conversation stays exactly as it was. Many thanks to Peer Hogeterp (`peerschuett`) and Jens Erler (`j-erler`) for this feature. - Added a way to roll a chat back to an earlier AI response. The response you pick stays, and every message after it is removed permanently, together with the attachments of those messages. - 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 Hogeterp (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature. -- Added Outlook Mail for company Exchange on Windows. After your organization sets its Exchange address, you can ask a trusted model to search and read messages in your primary mailbox without opening Outlook or entering a password. The tool does not read attachments or shared mailboxes. -- 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 Outlook Mail for company Exchange on Windows. After your organization sets its Exchange address, you can ask a model to search and read messages in your primary mailbox without opening Outlook or entering a password. The tool does not read attachments or shared mailboxes. +- Added safeguards to the Outlook Mail tool: it works only with a High-confidence provider or one your organization trusts, whatever you choose in the tool settings. +- Added a newest-first order to the Outlook Mail search. When many messages match, you get the most recent ones across all your mail folders, since those are most often the ones you are looking for. - 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. - Added tools to the assistants. Each assistant has its own tool settings: which tools it starts with and whether you get to change them while you work. The chat, the coding assistant, and the Slide Builder always show the selection; for every other assistant you switch it on where you want it. @@ -52,7 +53,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. +- 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 messages from your company mailbox. 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 5072cf02..2bde1807 100644 --- a/documentation/Tools.md +++ b/documentation/Tools.md @@ -54,7 +54,7 @@ Keep `Function.DescriptionForLLM` focused on what the tool does. This value is m A setting offering a fixed choice takes it from an option source — `RequiredChoice` and `OptionalChoice` name a list the app maintains, see `ToolSettingsOptionSources` — or spells its values out in the field's `enum` list, which is how a definition arriving as data offers a choice of its own. The two are mutually exclusive, and `ToolRegistry` rejects a definition that uses both or names an unknown source. Check a stored value in `ValidateConfigurationAsync` either way: it can predate the current list or arrive from an organization's configuration. -When a tool returns data that future messages must only send to providers at or above a specific confidence level, set `ToolExecutionResult.RequiredProviderConfidence`. AI Studio persists the highest requirement reached by the chat and applies it to later provider checks. Organization trust does not override the persisted confidence threshold. +When a tool returns data that future messages must only send to providers at or above a specific confidence level, set `ToolExecutionResult.RequiredProviderConfidence`. AI Studio persists the highest requirement reached by the chat and applies it to later provider checks. Provider instances listed in `DataSourceSecuritySettings.TrustedProviderIds` count as High-confidence providers and may also continue chats containing data protected this way. ## Security @@ -96,9 +96,9 @@ Every successfully retrieved page with readable content is also returned as a st ## Outlook Mail -`outlook_mail` is a Windows-only tool for the signed-in employee's primary Exchange mailbox. It requires an HTTPS EWS endpoint ending in `/EWS/Exchange.asmx` and uses Windows integrated authentication. The optional Outlook Web URL is only used to build links when Exchange supplies a message path. Search enumerates folders below `msgfolderroot` and searches each with `FindItem`; read uses `GetItem` and checks the returned parent folder against that enumeration. Folder and result limits can make a search partial. No mailbox address, password, or attachment content is accepted or fetched. +`outlook_mail` is a Windows-only tool for the signed-in employee's primary Exchange mailbox. It requires an HTTPS EWS endpoint ending in `/EWS/Exchange.asmx` and uses Windows integrated authentication. The optional Outlook Web URL is only used to build links when Exchange supplies a message path. Search enumerates folders below `msgfolderroot`, searches each with `FindItem` sorted by `DateTimeReceived` descending, and returns the most recent matches across all folders; read uses `GetItem` and checks the returned parent folder against that enumeration. Folder and result limits can make a search partial. No mailbox address, password, or attachment content is accepted or fetched. -The tool accepts a High-confidence or organization-trusted provider for a call, independently of the adjustable tool confidence setting. Search terms and message IDs are redacted from traces, and mail results are omitted from the trace while still returned to the model. Every mail field returned to the model goes through `PromptInjectionGuardService`. A successful call marks the chat as requiring High confidence; a configuration-trusted provider below High therefore cannot continue that chat under the current chat confidence rule. +The tool accepts a High-confidence or organization-trusted provider for a call, independently of the adjustable tool confidence setting. Search terms and message IDs are redacted from traces, and mail results are omitted from the trace while still returned to the model. Every mail field returned to the model goes through `PromptInjectionGuardService`. A successful call marks the chat as requiring High confidence, which a configuration-trusted provider also meets, so it can continue the chat. ## Checklist