Added a way to switch off tools you never need (#1115)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-27 08:48:52 +01:00
1 parent dcba62b45c
commit 04546c25ac
31 files changed
+638 -37

No files matched your search

@@ -540,6 +540,24 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
});
}
[Test]
public async Task AToolYouSwitchedOffIsNotMissing()
{
var registry = this.CreateRegistry(new TestTool(Definition()));
registry.SetSwitchedOffByUser(TOOL_ID, true);
var policy = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportPolicy(allowedToolIds: [TOOL_ID]), PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), this.SettingsManager, registry);
var template = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], [TOOL_ID]), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, registry);
Assert.Multiple(() =>
{
Assert.That(policy.Hints, Is.Empty, "The tool exists on this computer; switching it on again makes the policy use it.");
Assert.That(template.Hints, Is.Empty);
Assert.That(((DataDocumentAnalysisPolicy)policy.ConfigurationObject).AllowedToolIds, Is.EquivalentTo(new[] { TOOL_ID }), "The import keeps the tool.");
Assert.That(((ChatTemplate)template.ConfigurationObject).ToolIds, Is.EquivalentTo(new[] { TOOL_ID }));
});
}
[Test]
public async Task AChatTemplateWhichSaysNothingAboutToolsKeepsSayingNothing()
{
@@ -48,6 +48,14 @@ public sealed class ClassicRagStandsBackTests : ToolRegistryTestBase
Assert.That(await this.IsSearchedByTheModelAsync(DataSourceRetrievalMode.SEMANTIC_SEARCH, ToolCapableProvider()), Is.False);
}
[Test]
public async Task ItSearchesWhenTheUserSwitchedTheToolOff()
{
this.SettingsManager.ConfigurationData.Tools.UserDisabledToolIds.Add(ToolSelectionRules.SEMANTIC_SEARCH_TOOL_ID);
Assert.That(await this.IsSearchedByTheModelAsync(DataSourceRetrievalMode.SEMANTIC_SEARCH, ToolCapableProvider()), Is.False);
}
[Test]
public async Task ItSearchesWithoutARegistry()
{
@@ -66,6 +66,16 @@ public sealed class EffectiveRetrievalModeTests : ToolRegistryTestBase
Assert.That(mode, Is.EqualTo(new EffectiveRetrievalMode(DataSourceRetrievalMode.EVERY_MESSAGE, ToolOfferBlockReason.TOOL_SWITCHED_OFF)), "An organization which switches the tool off must not switch off the data sources along with it.");
}
[Test]
public async Task SwitchingTheToolOffYourselfGetsEveryMessageSearched()
{
this.SettingsManager.ConfigurationData.Tools.UserDisabledToolIds.Add(ToolSelectionRules.SEMANTIC_SEARCH_TOOL_ID);
var mode = await this.GetModeAsync(DataSourceRetrievalMode.SEMANTIC_SEARCH, ToolCapableProvider());
Assert.That(mode, Is.EqualTo(new EffectiveRetrievalMode(DataSourceRetrievalMode.EVERY_MESSAGE, ToolOfferBlockReason.TOOL_SWITCHED_OFF_BY_USER)), "A user who switches the tool off still wants their data sources searched.");
}
[Test]
public async Task OutsideTheChatEveryMessageIsSearched()
{
@@ -72,6 +72,23 @@ public sealed class ToolRegistryOfferTests : ToolRegistryTestBase
await this.AssertBothAgree(this.CreateRegistry(new TestTool(Definition())), ToolCapableProvider(), ToolOfferBlockReason.TOOL_SWITCHED_OFF, "The organization turned this one tool off.");
}
[Test]
public async Task AToolSwitchedOffByTheUser()
{
this.SettingsManager.ConfigurationData.Tools.UserDisabledToolIds.Add(TOOL_ID);
await this.AssertBothAgree(this.CreateRegistry(new TestTool(Definition())), ToolCapableProvider(), ToolOfferBlockReason.TOOL_SWITCHED_OFF_BY_USER, "The user never needs this tool and switched it off.");
}
[Test]
public async Task TheOrganizationComesBeforeTheUser()
{
this.SettingsManager.ConfigurationData.Tools.DisabledToolIds.Add(TOOL_ID);
this.SettingsManager.ConfigurationData.Tools.UserDisabledToolIds.Add(TOOL_ID);
await this.AssertBothAgree(this.CreateRegistry(new TestTool(Definition())), ToolCapableProvider(), ToolOfferBlockReason.TOOL_SWITCHED_OFF, "Switching the tool on again would not help the user, so the organization is the reason which counts.");
}
[Test]
public async Task AToolMissingASetting()
{
@@ -0,0 +1,211 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.ToolCalling;
/// <summary>
/// Checks that a tool collection the user switched off disappears from every selection and from every request, and comes back when switched on.
/// </summary>
/// <remarks>
/// Somebody without a Confluence wiki never needs its search, and a tool that stays in every
/// selection with a warning about its missing settings only gets in the way. Switching it off has
/// to hide it everywhere a tool can be picked, and the model must neither get nor call it. The
/// settings still list it, or the user could never switch it on again, and selections keep naming
/// it, so switching it on brings it back wherever it was selected.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ToolSwitchedOffByUserTests : ToolRegistryTestBase
{
private const string OTHER_TOOL_ID = "other_tool";
private const string COLLECTION_ID = "test_collection";
private const string SEARCH_TOOL_ID = "test_search";
private const string READ_TOOL_ID = "test_read";
[Test]
public async Task ASwitchedOffToolAppearsInNoSelectionButInTheSettings()
{
var registry = this.CreateRegistry(new TestTool(Definition()), new TestTool(Definition(OTHER_TOOL_ID)));
registry.SetSwitchedOffByUser(TOOL_ID, true);
var forTheChat = await registry.GetCatalogAsync(AIStudio.Tools.Components.CHAT);
var forTheSettings = await registry.GetCatalogAsync(registry.GetAllDefinitions());
var forItsDialog = await registry.GetCatalogItemAsync(TOOL_ID);
Assert.Multiple(() =>
{
Assert.That(forTheChat.Select(item => item.Id), Is.EqualTo(new[] { OTHER_TOOL_ID }), "Every selection is built from this list.");
Assert.That(forTheSettings.Single(item => item.Id == TOOL_ID).SwitchOffReason, Is.EqualTo(ToolOfferBlockReason.TOOL_SWITCHED_OFF_BY_USER), "Otherwise, the user could never switch it on again.");
Assert.That(forItsDialog?.IsActive, Is.False, "The settings dialog of the tool still opens.");
});
}
[Test]
public async Task AToolTheOrganizationSwitchedOffStaysInTheSelection()
{
var registry = this.CreateRegistry(new TestTool(Definition()));
this.SettingsManager.ConfigurationData.Tools.DisabledToolIds.Add(TOOL_ID);
var forTheChat = await registry.GetCatalogAsync(AIStudio.Tools.Components.CHAT);
Assert.That(forTheChat.Single().SwitchOffReason, Is.EqualTo(ToolOfferBlockReason.TOOL_SWITCHED_OFF), "Shown locked, so the user learns why the tool does not work.");
}
[Test]
public async Task ASwitchedOffToolIsNeitherCountedNorPreselected()
{
var registry = this.CreateRegistry(new TestTool(Definition()), new TestTool(Definition(OTHER_TOOL_ID)));
registry.SetSwitchedOffByUser(TOOL_ID, true);
var counted = registry.FilterToolIdsForProvider(ToolCapableProvider(), [TOOL_ID, OTHER_TOOL_ID], OutboundDataRestriction.UNRESTRICTED);
var preselected = await registry.FilterSelectableToolIdsAsync(AIStudio.Tools.Components.CHAT, [TOOL_ID, OTHER_TOOL_ID]);
Assert.Multiple(() =>
{
Assert.That(counted, Is.EquivalentTo(new[] { OTHER_TOOL_ID }), "The token count below the message field.");
Assert.That(preselected, Is.EquivalentTo(new[] { OTHER_TOOL_ID }), "A chat template or a launcher which names the tool.");
});
}
[Test]
public async Task AModelCannotCallASwitchedOffTool()
{
var wasCalled = false;
var registry = this.CreateRegistry(new TestTool(Definition(), execute: _ =>
{
wasCalled = true;
return new ToolExecutionResult();
}));
registry.SetSwitchedOffByUser(TOOL_ID, true);
var runnableTools = await registry.GetRunnableToolsAsync(this.ContextFor(ToolCapableProvider()), [TOOL_ID], mayRunTools: true);
// A model which calls the tool anyway, e.g., because an earlier answer in the chat named it:
var executor = new ToolExecutor(this.CreateToolSettingsService(), NullLogger<ToolExecutor>.Instance);
var outcome = await executor.ExecuteAsync("call-1", TOOL_ID, "{}", runnableTools, new NoProvider(), new ChatThread(), order: 1);
Assert.Multiple(() =>
{
Assert.That(runnableTools, Is.Empty, "The request does not offer the tool.");
Assert.That(outcome.Trace.Status, Is.EqualTo(ToolInvocationTraceStatus.BLOCKED));
Assert.That(wasCalled, Is.False);
});
}
[Test]
public async Task ASelectedToolComesBackWhenSwitchedOnAgain()
{
var registry = this.CreateRegistry(new TestTool(Definition()));
string[] selection = [TOOL_ID];
registry.SetSwitchedOffByUser(TOOL_ID, true);
var whileSwitchedOff = await registry.GetRunnableToolsAsync(this.ContextFor(ToolCapableProvider()), selection, mayRunTools: true);
registry.SetSwitchedOffByUser(TOOL_ID, false);
var afterSwitchingOn = await registry.GetRunnableToolsAsync(this.ContextFor(ToolCapableProvider()), selection, mayRunTools: true);
Assert.Multiple(() =>
{
Assert.That(whileSwitchedOff, Is.Empty);
Assert.That(afterSwitchingOn.Select(tool => tool.Definition.Id), Is.EqualTo(new[] { TOOL_ID }), "The selection kept the tool all along.");
Assert.That(this.StoredIds, Is.Empty);
});
}
[Test]
public async Task SwitchingOffOneToolSwitchesOffItsWholeCollection()
{
var registry = this.CreateCollection();
registry.SetSwitchedOffByUser(READ_TOOL_ID, true);
var reason = await registry.GetOfferBlockReasonAsync(SEARCH_TOOL_ID, ToolCapableProvider(), AIStudio.Tools.Components.CHAT);
Assert.Multiple(() =>
{
Assert.That(this.StoredIds, Is.EquivalentTo(new[] { COLLECTION_ID }), "The choice belongs to the collection, not to the tool it was made for.");
Assert.That(registry.IsSwitchedOffByUser(SEARCH_TOOL_ID), Is.True);
Assert.That(reason, Is.EqualTo(ToolOfferBlockReason.TOOL_SWITCHED_OFF_BY_USER));
});
}
[Test]
public void SwitchingACollectionOnRemovesTheEntriesOfItsTools()
{
var registry = this.CreateCollection();
this.StoredIds.Add(READ_TOOL_ID);
var whileSwitchedOff = registry.IsToolActive(SEARCH_TOOL_ID);
registry.SetSwitchedOffByUser(COLLECTION_ID, false);
Assert.Multiple(() =>
{
Assert.That(whileSwitchedOff, Is.False, "An entry for one tool counts for its whole collection.");
Assert.That(registry.IsToolActive(SEARCH_TOOL_ID), Is.True, "The old entry of the reader would keep the collection switched off.");
Assert.That(this.StoredIds, Is.Empty);
});
}
[Test]
public void WhatTheUserSwitchedOffStaysOffWhenTheOrganizationLetsGo()
{
var registry = this.CreateRegistry(new TestTool(Definition()));
registry.SetSwitchedOffByUser(TOOL_ID, true);
this.SettingsManager.ConfigurationData.Tools.DisabledToolIds.Add(TOOL_ID);
var whileBothApply = registry.IsSwitchedOffByUser(TOOL_ID);
this.SettingsManager.ConfigurationData.Tools.DisabledToolIds.Remove(TOOL_ID);
Assert.Multiple(() =>
{
Assert.That(whileBothApply, Is.True);
Assert.That(registry.IsToolActive(TOOL_ID), Is.False);
});
}
[Test]
public void ASelectionFieldShowsNoSwitchedOffTool()
{
var registry = this.CreateCollection();
registry.SetSwitchedOffByUser(COLLECTION_ID, true);
var shown = registry.LeaveOutSwitchedOffByUser([READ_TOOL_ID, TOOL_ID]);
Assert.That(shown, Is.EquivalentTo(new[] { TOOL_ID }), "The field lists no switched-off tool, so it must not count one as selected either.");
}
[Test]
public void ChangingASelectionKeepsTheToolsSwitchedOff()
{
var registry = this.CreateCollection();
registry.SetSwitchedOffByUser(COLLECTION_ID, true);
// The field showed only the other tool, and the user took it out:
var stored = registry.KeepSwitchedOffByUser([READ_TOOL_ID, TOOL_ID], []);
Assert.That(stored, Is.EquivalentTo(new[] { COLLECTION_ID }), "Switching the collection on again brings it back in the template.");
}
[Test]
public void ChangingASelectionDropsWhatTheUserDeselected()
{
var registry = this.CreateCollection();
var stored = registry.KeepSwitchedOffByUser([READ_TOOL_ID, TOOL_ID], [TOOL_ID]);
Assert.That(stored, Is.EquivalentTo(new[] { TOOL_ID }), "Nothing is switched off, so nothing comes back on its own.");
}
private HashSet<string> StoredIds => this.SettingsManager.ConfigurationData.Tools.UserDisabledToolIds;
/// <summary>
/// A search and a reader in one collection, next to a tool of its own.
/// </summary>
private ToolRegistry CreateCollection() => this.CreateRegistry(
[new TestCollection(COLLECTION_ID, ConfidenceLevel.NONE, SEARCH_TOOL_ID, READ_TOOL_ID)],
new TestTool(Definition(SEARCH_TOOL_ID)),
new TestTool(Definition(READ_TOOL_ID)),
new TestTool(Definition()));
}