using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings.DataModel; using AIStudio.Tools.ToolCallingSystem; using Microsoft.Extensions.Logging.Abstractions; namespace AIStudio.Tests.Tools.ToolCalling; /// /// Checks which tools a chat may still use once it read from a mailbox. /// /// /// Every argument a model writes may carry the mail it read. A web search would send it to a /// search engine, a web page address to whoever runs the server. A chat restricted by its mailbox /// must therefore neither be offered such a tool nor run it, while the services configured in /// AI Studio stay allowed on every level. /// [TestFixture] [NonParallelizable] public sealed class ToolOutboundDataTests : ToolRegistryTestBase { private const string MAILBOX_ID = "6e3a9d2f-1b7c-4f8e-a5d4-9c2b7e1f3a6d"; [TestCase(OutboundDataRestriction.UNRESTRICTED, ToolOutboundData.NONE, true)] [TestCase(OutboundDataRestriction.UNRESTRICTED, ToolOutboundData.CONFIGURED_SERVICE, true)] [TestCase(OutboundDataRestriction.UNRESTRICTED, ToolOutboundData.THIRD_PARTY_QUERIES, true)] [TestCase(OutboundDataRestriction.UNRESTRICTED, ToolOutboundData.MODEL_CHOSEN_ADDRESSES, true)] [TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, ToolOutboundData.NONE, true)] [TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, ToolOutboundData.CONFIGURED_SERVICE, true)] [TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, ToolOutboundData.THIRD_PARTY_QUERIES, false)] [TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, ToolOutboundData.MODEL_CHOSEN_ADDRESSES, false)] [TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, ToolOutboundData.NONE, true)] [TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, ToolOutboundData.CONFIGURED_SERVICE, true)] [TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, ToolOutboundData.THIRD_PARTY_QUERIES, false)] [TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, ToolOutboundData.MODEL_CHOSEN_ADDRESSES, false)] public void WhereAToolSendsDataDecidesWhetherItMayRun(OutboundDataRestriction restriction, ToolOutboundData outboundData, bool expected) { var tool = new TestTool(Definition()) { OutboundData = outboundData }; Assert.That(ToolSelectionRules.IsOutboundDataAllowed(restriction, tool), Is.EqualTo(expected)); } [TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)] [TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)] public void AToolWhichKeepsToTheRestrictionItselfMayRun(OutboundDataRestriction restriction) { var tool = new TestTool(Definition()) { OutboundData = ToolOutboundData.MODEL_CHOSEN_ADDRESSES, EnforcesOutboundDataRestriction = true }; Assert.That(ToolSelectionRules.IsOutboundDataAllowed(restriction, tool), Is.True, "Read Web Page tells the addresses of the chat from others on its own."); } [Test] public void ALevelThisVersionDoesNotKnowCountsAsStrict() { var tool = new TestTool(Definition()) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES }; Assert.That(ToolSelectionRules.IsOutboundDataAllowed((OutboundDataRestriction)99, tool), Is.False); } [Test] public void AToolWhichSaysNothingCountsAsTheMostOpenKind() { IToolImplementation tool = new SilentTool(); Assert.Multiple(() => { Assert.That(tool.OutboundData, Is.EqualTo(ToolOutboundData.MODEL_CHOSEN_ADDRESSES)); Assert.That(tool.EnforcesOutboundDataRestriction, Is.False); }); } [Test] public async Task ARestrictedChatIsNotOfferedAToolWhichGoesTooFar() { var webSearch = new TestTool(Definition("web_search_test")) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES }; var wiki = new TestTool(Definition("wiki_search_test")) { OutboundData = ToolOutboundData.CONFIGURED_SERVICE }; var registry = this.CreateRegistry(webSearch, wiki); var restricted = await this.OfferedToolIds(registry, RestrictedChat(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)); var unrestricted = await this.OfferedToolIds(registry, new ChatThread()); Assert.Multiple(() => { Assert.That(restricted, Is.EqualTo(new[] { "wiki_search_test" }), "The configured wiki stays allowed, the web search is left out."); Assert.That(unrestricted, Is.EquivalentTo(new[] { "web_search_test", "wiki_search_test" }), "A chat which read no mailbox gets every tool it selected."); }); } [TestCase(OutboundDataRestriction.UNRESTRICTED)] [TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)] [TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)] public async Task TheToolsCountedForARequestAreTheOnesItOffers(OutboundDataRestriction restriction) { // // The token count below the message field and the tools a chat records for its request // both come from FilterToolIdsForProvider. A tool which the request leaves out, but which // the count still counts, makes the number wrong in exactly the chats which read mails: // var webSearch = new TestTool(Definition("web_search_test")) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES }; var wiki = new TestTool(Definition("wiki_search_test")) { OutboundData = ToolOutboundData.CONFIGURED_SERVICE }; var registry = this.CreateRegistry(webSearch, wiki); var thread = restriction is OutboundDataRestriction.UNRESTRICTED ? new ChatThread() : RestrictedChat(restriction); var counted = registry.FilterToolIdsForProvider(ToolCapableProvider(), ["web_search_test", "wiki_search_test"], thread.RequiredOutboundDataRestriction.Restriction); var offered = await this.OfferedToolIds(registry, thread); Assert.That(counted, Is.EquivalentTo(offered)); } [Test] public async Task AToolWhichKeepsToTheRestrictionItselfIsOffered() { var readWebPage = new TestTool(Definition()) { OutboundData = ToolOutboundData.MODEL_CHOSEN_ADDRESSES, EnforcesOutboundDataRestriction = true }; var offered = await this.OfferedToolIds(this.CreateRegistry(readWebPage), RestrictedChat(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)); Assert.That(offered, Is.EqualTo(new[] { TOOL_ID })); } [Test] public async Task ACallWhichGoesTooFarIsTurnedDownBeforeTheToolRuns() { this.SettingsManager.ConfigurationData.Mailboxes.Add(new DataSourceMailbox { Id = MAILBOX_ID, Name = "Work mailbox" }); var hasRun = false; var webSearch = new TestTool(Definition(), execute: _ => { hasRun = true; return new ToolExecutionResult { TextContent = "Results about the budget." }; }) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES }; // // The request offered the tool while the chat was still unrestricted. Then a mail tool // brought in the mail, and the model calls the web search with it in the same request: // var executor = new ToolExecutor(this.CreateToolSettingsService(), NullLogger.Instance); var outcome = await executor.ExecuteAsync("call-1", TOOL_ID, "{}", [(webSearch.GetDefinition(), webSearch)], new NoProvider(), RestrictedChat(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT), order: 1); Assert.Multiple(() => { Assert.That(hasRun, Is.False, "The mail would already have left AI Studio."); Assert.That(outcome.Trace.Status, Is.EqualTo(ToolInvocationTraceStatus.BLOCKED)); Assert.That(outcome.Content, Does.Contain("Work mailbox"), "The user learns which mailbox stands in the way."); Assert.That(outcome.Content, Does.Contain(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT.GetName())); }); } [Test] public void AMailboxRemovedSinceIsNotNamed() { var requirement = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, MAILBOX_ID); var message = requirement.GetToolBlockedMessage([new DataSourceMailbox { Id = "another-mailbox", Name = "Private mailbox" }]); Assert.Multiple(() => { Assert.That(message, Does.Contain("removed since")); Assert.That(message, Does.Not.Contain("Private mailbox"), "Another mailbox did not restrict the chat."); Assert.That(message, Does.Contain(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES.GetName()), "The restriction stays, so the user still learns why."); }); } private static ChatThread RestrictedChat(OutboundDataRestriction restriction) { var thread = new ChatThread(); thread.RequireOutboundDataRestriction(new(restriction, MAILBOX_ID)); return thread; } private async Task> OfferedToolIds(ToolRegistry registry, ChatThread thread) { var provider = ToolCapableProvider(); var context = new ToolResolutionContext { Provider = provider, Component = AIStudio.Tools.Components.CHAT, ProviderConfidence = provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level, ChatThread = thread, }; var runnableTools = await registry.GetRunnableToolsAsync(context, registry.GetAllDefinitions().Select(x => x.Id), mayRunTools: true); return runnableTools.Select(x => x.Definition.Id).ToList(); } /// /// A tool which states nothing about where it sends data. /// private sealed class SilentTool : IToolImplementation { public string ImplementationKey => "silent_tool"; public IReadOnlySet SensitiveTraceArgumentNames { get; } = new HashSet(StringComparer.Ordinal); public ToolDefinition GetDefinition() => Definition("silent_tool"); public Task ExecuteAsync(JsonElement arguments, ToolExecutionContext context, CancellationToken token = default) => Task.FromResult(new ToolExecutionResult()); } }