2026-09-04 15:48:07 +02:00
using System.Text.Json ;
using AIStudio.Provider ;
using AIStudio.Settings ;
2026-09-27 16:26:52 +02:00
using AIStudio.Settings.DataModel ;
2026-09-04 15:48:07 +02:00
namespace AIStudio.Tools.ToolCallingSystem ;
/// <summary>
/// Holds the tools AI Studio knows and decides which of them a request may use.
/// </summary>
/// <remarks>
/// Definitions arrive through tool definition sources — the app's own tools from code, later the
/// ones plugin authors write. Every definition passes the same validation regardless of where it
2026-10-04 12:26:26 +02:00
/// came from, which matters most for the ones AI Studio does not control.<br/><br/>
/// Every tool belongs to exactly one collection, see ToolCollectionDefinition: a declared one, or
/// one of its own under its own ID. Whether a tool is switched off and which confidence it needs
/// are questions about its collection, so they are answered here, where the collections are known.
2026-09-04 15:48:07 +02:00
/// </remarks>
public sealed class ToolRegistry
{
private readonly ILogger < ToolRegistry > logger ;
private readonly SettingsManager settingsManager ;
private readonly ToolSettingsService toolSettingsService ;
private readonly Dictionary < string , ToolDefinition > definitionsById = new ( StringComparer . Ordinal );
private readonly Dictionary < string , IToolImplementation > implementationsByKey = new ( StringComparer . Ordinal );
2026-10-04 12:26:26 +02:00
private readonly Dictionary < string , RegisteredCollection > collectionsById = new ( StringComparer . Ordinal );
private readonly Dictionary < string , string > collectionIdsByToolId = new ( StringComparer . Ordinal );
/// <summary>
/// A declared collection as registered, holding only the tools which are registered themselves.
/// </summary>
/// <param name="Definition">The definition, reduced to the registered tools.</param>
/// <param name="Collection">The collection, which presents the definition.</param>
private sealed record RegisteredCollection ( ToolCollectionDefinition Definition , IToolCollection Collection );
2026-09-04 15:48:07 +02:00
2026-09-27 16:26:52 +02:00
/// <summary>
/// What the checks of a single tool found.
/// </summary>
/// <param name="BlockReason">What keeps the tool from being offered, or none.</param>
/// <param name="Implementation">The tool's implementation, once it was found.</param>
/// <param name="MinimumConfidence">The confidence the tool requires and where that requirement came from, once it was read.</param>
private readonly record struct ToolCheck ( ToolOfferBlockReason BlockReason , IToolImplementation ? Implementation , SettingsManager . ToolMinimumProviderConfidenceResolution ? MinimumConfidence );
2026-09-04 15:48:07 +02:00
public ToolRegistry (
IEnumerable < IToolImplementation > implementations ,
IEnumerable < IToolDefinitionSource > definitionSources ,
2026-10-04 12:26:26 +02:00
IEnumerable < IToolCollection > collections ,
2026-09-04 15:48:07 +02:00
SettingsManager settingsManager ,
ToolSettingsService toolSettingsService ,
ILogger < ToolRegistry > logger )
{
this . logger = logger ;
this . settingsManager = settingsManager ;
this . toolSettingsService = toolSettingsService ;
foreach ( var implementation in implementations )
{
if ( string . IsNullOrWhiteSpace ( implementation . ImplementationKey ))
{
this . logger . LogWarning ( "Skipping a tool implementation with an empty implementation key." );
continue ;
}
if (! this . implementationsByKey . TryAdd ( implementation . ImplementationKey , implementation ))
this . logger . LogWarning ( "Skipping duplicate tool implementation key '{ImplementationKey}'." , implementation . ImplementationKey );
}
//
// Function names are checked across all sources together: two tools offering the same
// name would be indistinguishable to a model, no matter who defined them.
//
var functionNames = new HashSet < string >( StringComparer . Ordinal );
foreach ( var source in definitionSources )
{
foreach ( var definition in source . GetDefinitions ())
{
if (! TryValidateDefinition ( definition , out var validationIssue ))
{
this . logger . LogWarning ( "Skipping tool definition '{ToolId}' from source '{SourceName}': {ValidationIssue}" , definition . Id , source . SourceName , validationIssue );
continue ;
}
if (! this . implementationsByKey . ContainsKey ( definition . ImplementationKey ))
{
this . logger . LogWarning ( "Skipping tool definition '{ToolId}' because implementation key '{ImplementationKey}' is not registered." , definition . Id , definition . ImplementationKey );
continue ;
}
if (! this . definitionsById . TryAdd ( definition . Id , definition ))
{
this . logger . LogWarning ( "Skipping duplicate tool definition ID '{ToolId}' from source '{SourceName}'." , definition . Id , source . SourceName );
continue ;
}
if (! functionNames . Add ( definition . Function . Name ))
{
this . logger . LogWarning ( "Skipping tool definition '{ToolId}' because function name '{FunctionName}' is already registered." , definition . Id , definition . Function . Name );
this . definitionsById . Remove ( definition . Id );
}
}
}
2026-10-04 12:26:26 +02:00
// After the tools, since a collection can only gather tools which are registered:
foreach ( var collection in collections )
this . RegisterCollection ( collection );
}
/// <summary>
/// Registers a declared tool collection, with those of its tools which are registered.
/// </summary>
/// <remarks>
/// A tool belongs to one collection at most, or switching one collection off would take a tool
/// of another one along. A collection may not take the ID of a tool either, since a tool which
/// belongs to no collection forms one under its own ID. A tool which offers itself from the
/// context of a chat stays out, because nobody selects it, see ToolActivation.CONTEXT.<br/><br/>
/// A tool the collection names but which is not registered is left out with a warning, so the
/// others still work as one. A collection left without any tool is skipped.
/// </remarks>
private void RegisterCollection ( IToolCollection collection )
{
var definition = collection . GetDefinition ();
if ( string . IsNullOrWhiteSpace ( definition . Id ))
{
this . logger . LogWarning ( "Skipping a tool collection with an empty ID." );
return ;
}
if ( this . definitionsById . ContainsKey ( definition . Id ))
{
this . logger . LogWarning ( "Skipping tool collection '{CollectionId}' because a tool has the same ID." , definition . Id );
return ;
}
if ( this . collectionsById . ContainsKey ( definition . Id ))
{
this . logger . LogWarning ( "Skipping duplicate tool collection ID '{CollectionId}'." , definition . Id );
return ;
}
var toolIds = new List < string >( definition . ToolIds . Count );
foreach ( var toolId in definition . ToolIds . Distinct ( StringComparer . Ordinal ))
{
if ( this . definitionsById . GetValueOrDefault ( toolId ) is not { } toolDefinition )
this . logger . LogWarning ( "Leaving tool '{ToolId}' out of tool collection '{CollectionId}' because the tool is not registered." , toolId , definition . Id );
else if ( toolDefinition . Activation is not ToolActivation . SELECTION )
this . logger . LogWarning ( "Leaving tool '{ToolId}' out of tool collection '{CollectionId}' because nobody selects the tool." , toolId , definition . Id );
else if ( this . collectionIdsByToolId . TryGetValue ( toolId , out var otherCollectionId ))
this . logger . LogWarning ( "Leaving tool '{ToolId}' out of tool collection '{CollectionId}' because it belongs to tool collection '{OtherCollectionId}' already." , toolId , definition . Id , otherCollectionId );
else
toolIds . Add ( toolId );
}
if ( toolIds . Count == 0 )
{
this . logger . LogWarning ( "Skipping tool collection '{CollectionId}' because none of its tools is registered." , definition . Id );
return ;
}
this . collectionsById [ definition . Id ] = new ( definition with { ToolIds = toolIds }, collection );
foreach ( var toolId in toolIds )
this . collectionIdsByToolId [ toolId ] = definition . Id ;
2026-09-04 15:48:07 +02:00
}
/// <summary>
/// Whether a tool definition is complete enough to register.
/// </summary>
/// <remarks>
/// What a definition cannot be is null in its parts: definitions are C# objects whose members
/// are non-nullable and initialized, so only their content is checked here. Should definitions
/// one day arrive from outside as data — a tool plugin, say — that assumption ends at the point
/// where the data becomes a definition, and it is there that null has to be caught.
/// </remarks>
private static bool TryValidateDefinition ( ToolDefinition definition , out string issue )
{
issue = string . Empty ;
if ( definition . SchemaVersion != 1 )
{
issue = $"unsupported schema version '{definition.SchemaVersion}'" ;
return false ;
}
if ( string . IsNullOrWhiteSpace ( definition . Id ))
{
issue = "the definition ID is empty" ;
return false ;
}
if ( string . IsNullOrWhiteSpace ( definition . ImplementationKey ))
{
issue = "the implementation key is empty" ;
return false ;
}
2026-10-09 21:25:23 +02:00
if (! ToolExecutor . IsValidFunctionName ( definition . Function . Name ))
2026-09-04 15:48:07 +02:00
{
issue = "the function name must contain 1-64 ASCII letters, digits, underscores, or hyphens" ;
return false ;
}
if ( definition . Function . Parameters . ValueKind is not JsonValueKind . Object )
{
issue = "the function parameters schema must be a JSON object" ;
return false ;
}
if ( definition . VisibleIn . AllowedComponents . Any ( component => ! Enum . IsDefined ( component )) ||
definition . VisibleIn . DeniedComponents . Any ( component => ! Enum . IsDefined ( component )))
{
issue = "the visibility definition must contain valid component lists" ;
return false ;
}
if (! string . Equals ( definition . SettingsSchema . Type , "object" , StringComparison . OrdinalIgnoreCase ))
{
issue = "the settings schema must have type 'object'" ;
return false ;
}
if ( definition . SettingsSchema . Properties . Any ( x =>
string . IsNullOrWhiteSpace ( x . Key ) ||
! string . Equals ( x . Value . Type , "string" , StringComparison . OrdinalIgnoreCase )))
{
issue = "settings properties must be named string fields" ;
return false ;
}
2026-09-04 22:14:29 +02:00
//
// An empty group is how a field says it belongs to no group. Whitespace looks the
// same in the settings file but is a different string, so it would open a second,
// nameless group next to the ungrouped fields:
//
var fieldsWithBlankGroup = definition . SettingsSchema . Properties
. Where ( x => x . Value . Group . Length > 0 && string . IsNullOrWhiteSpace ( x . Value . Group ))
. Select ( x => x . Key )
. ToList ();
if ( fieldsWithBlankGroup . Count > 0 )
{
issue = $"these settings declare a blank group name: {string.Join(" , ", fieldsWithBlankGroup)}" ;
return false ;
}
2026-09-04 15:48:07 +02:00
var fieldsWithBothOptionKinds = definition . SettingsSchema . Properties
. Where ( x => ! string . IsNullOrWhiteSpace ( x . Value . OptionSource ) && x . Value . EnumValues . Count > 0 )
. Select ( x => x . Key )
. ToList ();
if ( fieldsWithBothOptionKinds . Count > 0 )
{
issue = $"these settings declare both an option source and an enum list: {string.Join(" , ", fieldsWithBothOptionKinds)}" ;
return false ;
}
var fieldsWithUnknownOptionSource = definition . SettingsSchema . Properties
. Where ( x => ! string . IsNullOrWhiteSpace ( x . Value . OptionSource ) && ! ToolSettingsOptionSources . IsKnown ( x . Value . OptionSource ))
. Select ( x => $"{x.Key} ('{x.Value.OptionSource}')" )
. ToList ();
if ( fieldsWithUnknownOptionSource . Count > 0 )
{
issue = $"these settings reference an unknown option source: {string.Join(" , ", fieldsWithUnknownOptionSource)}" ;
return false ;
}
if ( definition . SettingsSchema . Required . Any ( string . IsNullOrWhiteSpace ))
{
issue = "required setting names cannot be empty" ;
return false ;
}
var missingRequiredProperties = definition . SettingsSchema . Required
. Where ( x => ! definition . SettingsSchema . Properties . ContainsKey ( x ))
. ToList ();
if ( missingRequiredProperties . Count > 0 )
{
issue = $"required settings are missing definitions: {string.Join(" , ", missingRequiredProperties)}" ;
return false ;
}
return true ;
}
public IReadOnlyList < ToolDefinition > GetDefinitionsForComponent ( Components component )
{
return this . definitionsById . Values
2026-10-04 12:26:26 +02:00
. Where ( x => x . VisibleIn . IsVisibleIn ( component ) && ! this . IsUnavailable ( x ))
2026-09-04 15:48:07 +02:00
. OrderBy ( x => this . implementationsByKey . GetValueOrDefault ( x . ImplementationKey )?. GetDisplayName (), StringComparer . OrdinalIgnoreCase )
. ToList ();
}
public IReadOnlyList < ToolDefinition > GetAllDefinitions () => this . definitionsById . Values
2026-10-04 12:26:26 +02:00
. Where ( x => ! this . IsUnavailable ( x ))
2026-09-04 15:48:07 +02:00
. OrderBy ( x => this . implementationsByKey . GetValueOrDefault ( x . ImplementationKey )?. GetDisplayName (), StringComparer . OrdinalIgnoreCase )
. ToList ();
2026-10-04 12:26:26 +02:00
/// <summary>
/// Whether the implementation of a tool says it does not exist right now, e.g., while its preview is switched off.
/// </summary>
/// <remarks>
/// A definition without an implementation is not unavailable in this sense: the lists keep it
/// as before, and every check leaves it out as a tool nobody knows.
/// </remarks>
private bool IsUnavailable ( ToolDefinition definition ) => this . implementationsByKey . GetValueOrDefault ( definition . ImplementationKey ) is { IsAvailable : false };
2026-09-04 15:48:07 +02:00
public ToolDefinition ? GetDefinition ( string toolId ) => this . definitionsById . GetValueOrDefault ( toolId );
public IToolImplementation ? GetImplementation ( string implementationKey ) => this . implementationsByKey . GetValueOrDefault ( implementationKey );
/// <summary>
2026-10-04 12:26:26 +02:00
/// The ID of the collection a tool belongs to.
/// </summary>
/// <remarks>
/// A tool which belongs to no declared collection forms one of its own, so this is its own ID
/// then. The ID of a collection stays as it is, and so does an ID the registry does not know,
/// such as one of a tool from another installation.
/// </remarks>
/// <param name="toolOrCollectionId">The ID of a tool, or of a collection.</param>
/// <returns>The ID of the collection.</returns>
public string GetCollectionId ( string toolOrCollectionId ) => this . collectionIdsByToolId . GetValueOrDefault ( toolOrCollectionId , toolOrCollectionId );
/// <summary>
/// Whether a tool may be used at all: tools are switched on, and the organization did not switch its collection off.
/// </summary>
/// <remarks>
/// An organization switches a collection off by its ID or by the ID of any of its tools. A
/// single tool of a collection cannot be switched off: the others would stop making sense, and
/// an administrator who names one tool would rather lose the collection than keep the tool.
/// </remarks>
/// <param name="toolOrCollectionId">The ID of a tool, or of a collection.</param>
/// <returns>True when the tool may be used.</returns>
public bool IsToolActive ( string toolOrCollectionId )
{
if (! this . settingsManager . AreToolsEnabled ())
return false ;
var disabledIds = this . settingsManager . ConfigurationData . Tools . DisabledToolIds ;
return ! this . GetSettingsIds ( this . GetCollectionId ( toolOrCollectionId )). Any ( disabledIds . Contains );
}
/// <summary>
/// The provider confidence a tool needs: the minimum of its collection, unless the user or an
/// administrator raised or lowered it.
2026-09-04 15:48:07 +02:00
/// </summary>
/// <remarks>
2026-10-04 12:26:26 +02:00
/// This is the place that knows both halves — the collection's own minimum and the stored
2026-09-04 15:48:07 +02:00
/// overrides — so callers holding only a tool ID come here instead of to the settings.
/// </remarks>
2026-10-04 12:26:26 +02:00
/// <param name="toolOrCollectionId">The ID of a tool, or of a collection.</param>
public ConfidenceLevel GetMinimumProviderConfidence ( string toolOrCollectionId ) => this . GetMinimumProviderConfidenceResolution ( toolOrCollectionId ). ConfidenceLevel ;
2026-09-04 15:48:07 +02:00
2026-10-04 12:26:26 +02:00
public ConfidenceLevel GetMinimumProviderConfidence ( ToolDefinition definition ) => this . GetMinimumProviderConfidence ( definition . Id );
2026-09-04 15:48:07 +02:00
/// <summary>
2026-10-04 12:26:26 +02:00
/// Stores which provider confidence the collection of a tool needs, as the user chose it.
/// </summary>
/// <remarks>
/// Whatever tool of a collection the level is chosen for, it is stored for the collection, so
/// all of its tools need the same level afterward.
/// </remarks>
/// <param name="toolOrCollectionId">The ID of a tool, or of a collection.</param>
/// <param name="confidenceLevel">The level the user chose. Choosing the collection's own minimum removes the override.</param>
public void SetMinimumProviderConfidence ( string toolOrCollectionId , ConfidenceLevel confidenceLevel )
{
var collectionId = this . GetCollectionId ( toolOrCollectionId );
this . settingsManager . SetMinimumProviderConfidence ( this . GetSettingsIds ( collectionId ), confidenceLevel , this . GetDefaultMinimumProviderConfidence ( collectionId ));
}
private SettingsManager . ToolMinimumProviderConfidenceResolution GetMinimumProviderConfidenceResolution ( string toolOrCollectionId )
{
var collectionId = this . GetCollectionId ( toolOrCollectionId );
return this . settingsManager . GetMinimumProviderConfidenceResolution ( this . GetSettingsIds ( collectionId ), this . GetDefaultMinimumProviderConfidence ( collectionId ));
}
/// <summary>
/// The minimum a collection asks for itself: the one it declares, or that of the tool which forms it.
/// </summary>
private ConfidenceLevel GetDefaultMinimumProviderConfidence ( string collectionId )
{
if ( this . collectionsById . TryGetValue ( collectionId , out var collection ))
return collection . Definition . MinimumProviderConfidence ;
return this . GetDefinition ( collectionId )?. MinimumProviderConfidence ?? ConfidenceLevel . NONE ;
}
/// <summary>
/// The IDs which stand for a collection in the settings: its own first, then those of its tools.
/// </summary>
/// <remarks>
/// The ID of a tool stands for its collection, so an entry made for the tool before it joined
/// the collection, or by an administrator who named the tool, still counts.
/// </remarks>
private IReadOnlyList < string > GetSettingsIds ( string collectionId ) => [ collectionId , .. this . GetToolIdsOfCollection ( collectionId ). Where ( toolId => toolId != collectionId )];
/// <summary>
/// The IDs of the tools of a collection: those of a declared one, or the ID of the tool which forms it.
/// </summary>
private IReadOnlyList < string > GetToolIdsOfCollection ( string collectionId ) => this . collectionsById . TryGetValue ( collectionId , out var collection )
? collection . Definition . ToolIds
: [ collectionId ];
/// <summary>
/// Whether this installation knows a tool or a tool collection by this ID.
/// </summary>
/// <param name="toolOrCollectionId">The ID of a tool, or of a collection.</param>
public bool IsKnown ( string toolOrCollectionId ) => this . definitionsById . ContainsKey ( toolOrCollectionId ) || this . collectionsById . ContainsKey ( toolOrCollectionId );
/// <summary>
/// Turns a selection into the collections which actually run.
/// </summary>
/// <remarks>
/// Every ID turns into the ID of its collection: a selection which names one tool of a
/// collection, such as one stored before the tool joined it, selects the whole collection.
/// Duplicates go, and so do the tools nobody selects. Semantic Search offers itself whenever the
/// data sources of a chat call for it, see ToolActivation.CONTEXT; kept in a selection, it would
/// appear on the security card of a plugin and in its audit without the selection having any
/// say in whether it runs. An ID this installation does not know stays, because the tool may
/// arrive with a plugin installed later.<br/><br/>
/// It also adds what a collection depends on: Search Confluence only finds pages, so it brings
/// Read Web Page along to open them. An added collection keeps its own rules. It is still
/// dropped when it is switched off or the provider's confidence is too low, and Read Web Page
/// reaches a wiki on a private or VPN address only when its host is allowed there.<br/><br/>
/// Every place which shows or stores a selection normalizes it, the tool selection fields
/// included. That way a chat, a template, a policy, or an assistant plugin shows the collections
/// which will actually run.
/// </remarks>
/// <param name="selectedIds">The IDs of the selected tools or collections.</param>
/// <returns>The IDs of the collections, as a new set.</returns>
public HashSet < string > NormalizeSelection ( IEnumerable < string > selectedIds )
{
var normalized = selectedIds . Select ( this . GetCollectionId ). ToHashSet ( StringComparer . Ordinal );
if ( normalized . Contains ( this . GetCollectionId ( ToolSelectionRules . SEARCH_CONFLUENCE_TOOL_ID )))
normalized . Add ( this . GetCollectionId ( ToolSelectionRules . READ_WEB_PAGE_TOOL_ID ));
normalized . RemoveWhere ( id => this . GetDefinition ( id ) is { Activation : ToolActivation . CONTEXT });
return normalized ;
}
/// <summary>
/// The tools a selection runs: each tool of each collection it selects.
/// </summary>
/// <remarks>
/// The one place where a collection turns into its tools. People select and configure
/// collections, but the model sees every tool on its own, so whatever prepares or counts a
/// request, and whatever shows what a plugin will run, comes here. An ID this installation does
/// not know stays as it is.
/// </remarks>
/// <param name="selectedIds">The IDs of the selected tools or collections.</param>
/// <returns>The IDs of the tools, as a new set.</returns>
public HashSet < string > ExpandSelection ( IEnumerable < string > selectedIds ) => this . NormalizeSelection ( selectedIds )
. SelectMany ( this . GetToolIdsOfCollection )
. ToHashSet ( StringComparer . Ordinal );
/// <summary>
/// The collections preselected in a component, as the user chose them in the settings.
/// </summary>
public HashSet < string > GetDefaultToolIds ( Components component ) => this . settingsManager . ConfigurationData . Tools . DefaultToolIdsByComponent . TryGetValue ( component . ToString (), out var toolIds )
? this . NormalizeSelection ( toolIds )
: [];
/// <summary>
/// Narrows a selection to the tools the given provider may actually use in this chat.
2026-09-04 15:48:07 +02:00
/// </summary>
/// <remarks>
/// Used before a request is sent, so the chat records what will really be available rather
2026-10-04 12:26:26 +02:00
/// than what the user once ticked, and by the token count below the message field, so a tool
/// the request leaves out does not count. Lives here because judging a tool needs its
/// collection: the settings know the overrides, the collection knows its own minimum.
/// Where the chat may still send data is judged with the rule the preparation of a request
/// uses, see CheckToolAsync.<br/><br/>
/// Returns tools rather than collections, since a collection may lose some of its tools here,
/// e.g., one which a mailbox read by the chat keeps back. Handed to a request again, they turn
/// into their whole collection once more, and the request leaves out the same tools again.
2026-09-04 15:48:07 +02:00
/// </remarks>
/// <param name="provider">The provider the request goes to.</param>
2026-10-04 12:26:26 +02:00
/// <param name="selectedToolIds">The tools or collections the user selected.</param>
/// <param name="outboundDataRestriction">Where the chat may still send data, see ChatThread.RequiredOutboundDataRestriction.</param>
/// <returns>The IDs of the selected tools that are enabled, active, available, allowed by the provider's confidence, and allowed by the outbound data restriction.</returns>
public HashSet < string > FilterToolIdsForProvider ( AIStudio . Settings . Provider provider , IEnumerable < string > selectedToolIds , OutboundDataRestriction outboundDataRestriction )
2026-09-04 15:48:07 +02:00
{
if (! this . settingsManager . AreToolsEnabled ())
return [];
if (! provider . GetToolCallingAvailability (). IsAvailable )
return [];
var providerConfidence = provider . UsedLLMProvider . GetConfidence ( this . settingsManager ). Level ;
2026-10-04 12:26:26 +02:00
var filtered = this . ExpandSelection ( selectedToolIds );
2026-09-04 15:48:07 +02:00
foreach ( var toolId in filtered . ToList ())
{
2026-10-04 12:26:26 +02:00
if (! this . IsToolActive ( toolId ))
2026-09-04 15:48:07 +02:00
{
filtered . Remove ( toolId );
continue ;
}
if (! ToolSelectionRules . IsProviderConfidenceAllowed ( providerConfidence , this . GetMinimumProviderConfidence ( toolId )))
2026-10-04 12:26:26 +02:00
{
filtered . Remove ( toolId );
continue ;
}
if ( this . GetDefinition ( toolId ) is not { } definition || ! this . implementationsByKey . TryGetValue ( definition . ImplementationKey , out var implementation ))
continue ;
if (! implementation . IsAvailable || ! ToolSelectionRules . IsOutboundDataAllowed ( outboundDataRestriction , implementation ))
2026-09-04 15:48:07 +02:00
filtered . Remove ( toolId );
}
return filtered ;
}
2026-09-27 16:26:52 +02:00
/// <summary>
2026-10-04 12:26:26 +02:00
/// The collections somebody can select in this component.
2026-09-27 16:26:52 +02:00
/// </summary>
/// <remarks>
/// Every selection in the app is built from this list: the one below the message field, the
/// defaults, the templates, and the tools the AI picks for a new assistant. A tool which offers
/// itself from the context of a chat is left out, because selecting it would change nothing.
/// The tool list of the app settings asks for all definitions instead, so an organization can
/// still switch such a tool off or set the trust it requires.
/// </remarks>
2026-09-04 15:48:07 +02:00
public async Task < IReadOnlyList < ToolCatalogItem >> GetCatalogAsync ( Components component )
{
2026-09-27 16:26:52 +02:00
var definitions = this . GetDefinitionsForComponent ( component ). Where ( x => x . Activation is ToolActivation . SELECTION );
2026-09-04 15:48:07 +02:00
return await this . GetCatalogAsync ( definitions );
}
/// <summary>
2026-10-04 12:26:26 +02:00
/// Reduces a set of IDs to the collections a user could switch on themselves in this component.
2026-09-04 15:48:07 +02:00
/// </summary>
/// <remarks>
/// For preselecting tools on someone's behalf, such as when a launcher opens a chat. A tool
/// this installation does not know, one an organization switched off, or one whose settings are
/// incomplete cannot be enabled by hand either, so handing it over as enabled would show the
/// user a state they could not have produced and could not fix from where they are. The
/// provider confidence stays out of this: it belongs to the moment a message is sent, not to
/// the selection, and it may well be a different provider by then.
/// </remarks>
public async Task < HashSet < string >> FilterSelectableToolIdsAsync ( Components component , IEnumerable < string > toolIds )
{
2026-10-04 12:26:26 +02:00
var wantedIds = this . NormalizeSelection ( toolIds );
if ( wantedIds . Count is 0 || ! this . settingsManager . AreToolsEnabled ())
2026-09-04 15:48:07 +02:00
return [];
var catalog = await this . GetCatalogAsync ( component );
return catalog
2026-10-04 12:26:26 +02:00
. Where ( x => wantedIds . Contains ( x . Id ) && x is { IsActive : true , ConfigurationState . IsConfigured : true })
. Select ( x => x . Id )
2026-09-04 15:48:07 +02:00
. ToHashSet ( StringComparer . Ordinal );
}
2026-10-04 12:26:26 +02:00
/// <summary>
/// The entry of one collection as people see it, with all of its tools which exist here.
/// </summary>
/// <param name="toolOrCollectionId">The ID of the collection, or of one of its tools.</param>
/// <returns>The entry, or null when none of its tools exists here.</returns>
public async Task < ToolCatalogItem ?> GetCatalogItemAsync ( string toolOrCollectionId )
{
var definitions = this . GetToolIdsOfCollection ( this . GetCollectionId ( toolOrCollectionId ))
. Select ( this . GetDefinition )
. OfType < ToolDefinition >();
return ( await this . GetCatalogAsync ( definitions )). SingleOrDefault ();
}
/// <summary>
/// The entries for these tools as people see them: one per collection, ordered by name.
/// </summary>
/// <remarks>
/// A collection holds only those of its tools which are among the given ones and exist right
/// now, so it disappears with the last of them, e.g., while the preview of its tools is off.
/// </remarks>
2026-09-04 15:48:07 +02:00
public async Task < IReadOnlyList < ToolCatalogItem >> GetCatalogAsync ( IEnumerable < ToolDefinition > definitions )
{
2026-10-04 12:26:26 +02:00
var toolsByCollectionId = new Dictionary < string , List < ToolCatalogTool >>( StringComparer . Ordinal );
foreach ( var definition in definitions )
2026-09-04 15:48:07 +02:00
{
2026-10-04 12:26:26 +02:00
if (! this . implementationsByKey . TryGetValue ( definition . ImplementationKey , out var implementation ) || ! implementation . IsAvailable )
2026-09-04 15:48:07 +02:00
continue ;
2026-10-04 12:26:26 +02:00
var collectionId = this . GetCollectionId ( definition . Id );
if (! toolsByCollectionId . TryGetValue ( collectionId , out var tools ))
2026-09-04 15:48:07 +02:00
{
2026-10-04 12:26:26 +02:00
tools = [];
toolsByCollectionId [ collectionId ] = tools ;
}
tools . Add ( new ( definition , implementation , await this . toolSettingsService . GetConfigurationStateAsync ( definition , implementation )));
2026-09-04 15:48:07 +02:00
}
2026-10-04 12:26:26 +02:00
return toolsByCollectionId
. Select ( entry => this . CreateCatalogItem ( entry . Key , entry . Value ))
. OrderBy ( item => item . DisplayName , StringComparer . OrdinalIgnoreCase )
. ToList ();
}
/// <summary>
/// Describes one collection with the given tools: a declared one as it presents itself, otherwise the tool which forms it.
/// </summary>
private ToolCatalogItem CreateCatalogItem ( string collectionId , List < ToolCatalogTool > tools )
{
var declaredCollection = this . collectionsById . GetValueOrDefault ( collectionId );
if ( declaredCollection is not null )
tools = declaredCollection . Definition . ToolIds . Join ( tools , toolId => toolId , tool => tool . Definition . Id , ( _ , tool ) => tool ). ToList ();
var firstTool = tools [ 0 ];
return new ()
{
Id = collectionId ,
Icon = declaredCollection ?. Collection . Icon ?? firstTool . Implementation . Icon ,
DisplayName = declaredCollection ?. Collection . GetDisplayName () ?? firstTool . Implementation . GetDisplayName (),
Description = declaredCollection ?. Collection . GetDescription () ?? firstTool . Implementation . GetDescription (),
DescriptionForLLM = declaredCollection ?. Definition . DescriptionForLLM ?? firstTool . Definition . Function . DescriptionForLLM ,
Tools = tools ,
ConfigurationState = tools . FirstOrDefault ( tool => ! tool . ConfigurationState . IsConfigured )?. ConfigurationState ?? new () { IsConfigured = true },
IsActive = this . IsToolActive ( collectionId ),
MinimumProviderConfidence = this . GetMinimumProviderConfidence ( collectionId ),
};
2026-09-04 15:48:07 +02:00
}
2026-09-27 16:26:52 +02:00
/// <summary>
/// The tools a request offers the model, each with the function it offers in this request.
/// </summary>
2026-09-04 15:48:07 +02:00
/// <remarks>
/// Model capabilities are not a parameter on purpose: they are read from the given provider,
/// which carries the user's expert capability overrides. Passing them in separately allowed a
2026-09-27 16:26:52 +02:00
/// caller to gate tools on capabilities that differed from the ones the availability check saw.<br/><br/>
2026-10-04 12:26:26 +02:00
/// The candidates are the tools of the selected collections and every tool which offers itself
/// from the context of the chat, see ToolActivation. Each one passes the same checks, and only
/// then is it asked what it offers in this request, see IToolImplementation.ResolveFunctionAsync
/// and IToolImplementation.ResolveSystemPromptInstructionsAsync.
2026-09-04 15:48:07 +02:00
/// </remarks>
2026-09-27 16:26:52 +02:00
/// <param name="context">The request being prepared.</param>
2026-10-04 12:26:26 +02:00
/// <param name="selectedToolIds">The tools or collections selected for the request.</param>
2026-09-27 16:26:52 +02:00
/// <param name="mayRunTools">Whether the request may run tools at all, as its caller decides.</param>
/// <param name="token">The cancellation token of the request.</param>
/// <returns>The runnable tools, with their definitions as offered in this request.</returns>
public async Task < IReadOnlyList <( ToolDefinition Definition , IToolImplementation Implementation )>> GetRunnableToolsAsync ( ToolResolutionContext context , IEnumerable < string > selectedToolIds , bool mayRunTools , CancellationToken token = default )
2026-09-04 15:48:07 +02:00
{
2026-09-27 16:26:52 +02:00
var provider = context . Provider ;
var component = context . Component ;
var providerConfidence = context . ProviderConfidence ;
2026-09-04 15:48:07 +02:00
if (! this . settingsManager . AreToolsEnabled ())
{
this . logger . LogDebug ( "Tool calling is skipped because tools are disabled by managed configuration." );
return [];
}
//
// Where the user selects the tools, they must be able to see that selection; where the
// assistant's own rules name them, there is nothing to see. Which of the two applies is
// decided by the caller, because only it knows where its tools came from:
//
if (! mayRunTools )
{
this . logger . LogDebug ( "Tool calling is skipped for component '{Component}' because its tool selection is hidden and no assistant rule names the tools." , component );
return [];
}
var toolCallingAvailability = provider . GetToolCallingAvailability ();
if (! toolCallingAvailability . IsAvailable )
{
this . logger . LogDebug ( "Tool calling is unavailable for provider '{Provider}' with model '{ModelId}': {Reason}" , provider . InstanceName , provider . Model . Id , toolCallingAvailability . Message );
return [];
}
2026-10-04 12:26:26 +02:00
var selectedToolIdSet = this . ExpandSelection ( selectedToolIds );
2026-09-04 15:48:07 +02:00
this . logger . LogDebug ( "Resolving runnable tools for provider '{Provider}' with model '{ModelId}'. Selected tool IDs: [{ToolIds}]." , provider . InstanceName , provider . Model . Id , string . Join ( ", " , selectedToolIdSet . OrderBy ( x => x , StringComparer . Ordinal )));
2026-09-27 16:26:52 +02:00
var definitions = this . GetDefinitionsForComponent ( component )
. Where ( x => x . Activation is ToolActivation . CONTEXT || selectedToolIdSet . Contains ( x . Id ))
. ToList ();
2026-10-04 12:26:26 +02:00
var outboundDataRestriction = context . ChatThread . RequiredOutboundDataRestriction . Restriction ;
2026-09-04 15:48:07 +02:00
var result = new List <( ToolDefinition , IToolImplementation )>( definitions . Count );
foreach ( var definition in definitions )
{
2026-10-04 12:26:26 +02:00
var check = await this . CheckToolAsync ( definition , providerConfidence , outboundDataRestriction );
2026-09-27 16:26:52 +02:00
if ( check . MinimumConfidence is { } minimumConfidence )
this . logger . LogDebug ( "Tool '{ToolId}' uses minimum provider confidence '{ConfidenceLevel}' from {Source}." , definition . Id , minimumConfidence . ConfidenceLevel , minimumConfidence . Source );
2026-09-04 15:48:07 +02:00
2026-09-27 16:26:52 +02:00
switch ( check )
2026-09-04 15:48:07 +02:00
{
2026-09-27 16:26:52 +02:00
case { BlockReason : ToolOfferBlockReason . NONE , Implementation : { } implementation }:
if ( await this . ResolveAsync ( definition , implementation , context , token ) is { } offeredDefinition )
result . Add (( offeredDefinition , implementation ));
2026-09-04 15:48:07 +02:00
2026-09-27 16:26:52 +02:00
break ;
2026-09-04 15:48:07 +02:00
2026-09-27 16:26:52 +02:00
case { BlockReason : ToolOfferBlockReason . TOOL_SWITCHED_OFF }:
this . logger . LogDebug ( "Skipping tool '{ToolId}' because it is disabled by managed configuration." , definition . Id );
break ;
2026-09-04 15:48:07 +02:00
2026-09-27 16:26:52 +02:00
case { BlockReason : ToolOfferBlockReason . NOT_CONFIGURED }:
this . logger . LogDebug ( "Skipping tool '{ToolId}' because it is not configured." , definition . Id );
break ;
2026-09-04 15:48:07 +02:00
2026-09-27 16:26:52 +02:00
case { BlockReason : ToolOfferBlockReason . PROVIDER_CONFIDENCE_TOO_LOW }:
this . logger . LogInformation ( "Skipping tool '{ToolId}' because provider confidence '{ProviderConfidence}' is below the required minimum '{MinimumConfidence}'." , definition . Id , providerConfidence , check . MinimumConfidence ?. ConfidenceLevel );
break ;
2026-10-04 12:26:26 +02:00
case { BlockReason : ToolOfferBlockReason . OUTBOUND_DATA_RESTRICTED }:
this . logger . LogInformation ( "Skipping tool '{ToolId}' because the chat read from a mailbox which restricts outbound data to '{OutboundDataRestriction}'." , definition . Id , outboundDataRestriction );
break ;
2026-09-27 16:26:52 +02:00
case { BlockReason : ToolOfferBlockReason . NOT_AVAILABLE_HERE }:
this . logger . LogWarning ( "Skipping tool '{ToolId}' because no implementation is registered." , definition . Id );
break ;
}
2026-09-04 15:48:07 +02:00
}
foreach ( var selectedToolId in selectedToolIdSet . Where ( selectedToolId => definitions . All ( definition => ! definition . Id . Equals ( selectedToolId , StringComparison . Ordinal ))))
this . logger . LogDebug ( "Skipping tool '{ToolId}' because it is not selected in this component or not available in this context." , selectedToolId );
return result ;
}
2026-09-27 16:26:52 +02:00
/// <summary>
/// Whether a tool can be offered to a provider in this component, and if not, what is in the way.
/// </summary>
/// <remarks>
/// Asks the same questions, in the same order, as the preparation of a request does, because
/// whoever decides something on the tool's behalf must not come to another answer than the
/// request will. The RAG process, for instance, leaves the searching of the data sources to
/// Semantic Search only when this says it can be offered; checks of its own which forgot one
/// of these would leave a chat without its data sources.<br/><br/>
2026-10-04 12:26:26 +02:00
/// Three questions stay out. Whether the tool is selected is the caller's business, and whether
2026-09-27 16:26:52 +02:00
/// the tool has anything to offer right now depends on the chat, so only the preparation of a
2026-10-04 12:26:26 +02:00
/// request can answer it. Where the chat may still send data belongs to the chat as well, see
/// ChatThread.RequiredOutboundDataRestriction, so the tool is judged as for a chat which read
/// no mailbox. Semantic Search, which the RAG process asks about, only reaches services
/// configured in AI Studio, and no restriction ever keeps it back.
2026-09-27 16:26:52 +02:00
/// </remarks>
/// <param name="toolId">The tool to check.</param>
/// <param name="provider">The provider the request would go to.</param>
/// <param name="component">Where the request would come from.</param>
/// <returns>ToolOfferBlockReason.NONE when nothing is in the way, otherwise the first obstacle found.</returns>
public async Task < ToolOfferBlockReason > GetOfferBlockReasonAsync ( string toolId , AIStudio . Settings . Provider provider , Components component )
{
if (! this . settingsManager . AreToolsEnabled ())
return ToolOfferBlockReason . TOOLS_SWITCHED_OFF ;
if (! provider . GetToolCallingAvailability (). IsAvailable )
return ToolOfferBlockReason . MODEL_CANNOT_USE_TOOLS ;
if ( this . GetDefinition ( toolId ) is not { } definition || ! definition . VisibleIn . IsVisibleIn ( component ))
return ToolOfferBlockReason . NOT_AVAILABLE_HERE ;
var providerConfidence = provider . UsedLLMProvider . GetConfidence ( this . settingsManager ). Level ;
2026-10-04 12:26:26 +02:00
return ( await this . CheckToolAsync ( definition , providerConfidence , OutboundDataRestriction . UNRESTRICTED )). BlockReason ;
2026-09-27 16:26:52 +02:00
}
/// <summary>
/// How the data sources of a chat are actually searched: the way the user wants, or with every
/// message when Semantic Search cannot be offered.
/// </summary>
/// <remarks>
/// The one place which decides between the two. The RAG process, the data source selection,
/// and the check of what a launched chat may search all ask here, so that none of them counts
/// the agents of the RAG process in or out while another does the opposite. The chat searches
/// itself when the user prefers it and GetOfferBlockReasonAsync has nothing against it. Its
/// answer comes along whatever the user prefers, so that the user interface can leave out a
/// choice which is none and say why the chat searches with every message.<br/><br/>
/// Whether Semantic Search has data sources to offer right now is no question here. Without
/// them, the RAG process would find nothing to search either: it asks the same checks, and more
/// providers have to pass them.
/// </remarks>
/// <param name="options">The data source options of the chat.</param>
/// <param name="provider">The provider the chat runs with.</param>
/// <param name="component">Where the chat runs.</param>
/// <returns>How the data sources are searched, and why Semantic Search cannot be used, if so.</returns>
public async Task < EffectiveRetrievalMode > GetEffectiveRetrievalModeAsync ( DataSourceOptions options , AIStudio . Settings . Provider provider , Components component )
{
var blockReason = await this . GetOfferBlockReasonAsync ( ToolSelectionRules . SEMANTIC_SEARCH_TOOL_ID , provider , component );
var searchesItself = options . RetrievalMode is DataSourceRetrievalMode . SEMANTIC_SEARCH && blockReason is ToolOfferBlockReason . NONE ;
return new ( searchesItself ? DataSourceRetrievalMode . SEMANTIC_SEARCH : DataSourceRetrievalMode . EVERY_MESSAGE , blockReason );
}
/// <summary>
/// Checks one tool on its own, apart from what applies to all tools of a request.
/// </summary>
/// <remarks>
/// Shared by the preparation of a request and by GetOfferBlockReasonAsync, so the two cannot
/// drift apart. It reports rather than logs: the preparation of a request writes down why a
/// tool was left out, while a question asked by the user interface on every render must not.
/// </remarks>
2026-10-04 12:26:26 +02:00
private async Task < ToolCheck > CheckToolAsync ( ToolDefinition definition , ConfidenceLevel providerConfidence , OutboundDataRestriction outboundDataRestriction )
2026-09-27 16:26:52 +02:00
{
2026-10-04 12:26:26 +02:00
if (! this . IsToolActive ( definition . Id ))
2026-09-27 16:26:52 +02:00
return new ( ToolOfferBlockReason . TOOL_SWITCHED_OFF , null , null );
2026-10-04 12:26:26 +02:00
if (! this . implementationsByKey . TryGetValue ( definition . ImplementationKey , out var implementation ) || ! implementation . IsAvailable )
2026-09-27 16:26:52 +02:00
return new ( ToolOfferBlockReason . NOT_AVAILABLE_HERE , null , null );
var configurationState = await this . toolSettingsService . GetConfigurationStateAsync ( definition , implementation );
if (! configurationState . IsConfigured )
return new ( ToolOfferBlockReason . NOT_CONFIGURED , implementation , null );
2026-10-04 12:26:26 +02:00
var minimumConfidence = this . GetMinimumProviderConfidenceResolution ( definition . Id );
2026-09-27 16:26:52 +02:00
if (! ToolSelectionRules . IsProviderConfidenceAllowed ( providerConfidence , minimumConfidence . ConfidenceLevel ))
return new ( ToolOfferBlockReason . PROVIDER_CONFIDENCE_TOO_LOW , implementation , minimumConfidence );
2026-10-04 12:26:26 +02:00
if (! ToolSelectionRules . IsOutboundDataAllowed ( outboundDataRestriction , implementation ))
return new ( ToolOfferBlockReason . OUTBOUND_DATA_RESTRICTED , implementation , minimumConfidence );
2026-09-27 16:26:52 +02:00
return new ( ToolOfferBlockReason . NONE , implementation , minimumConfidence );
}
/// <summary>
/// Asks a tool which passed every check what it offers in this request.
/// </summary>
/// <remarks>
2026-09-27 21:46:16 +02:00
/// Two answers are taken: the function, of which only the description and the parameters count,
/// and the instructions for the system prompt. The name and the strict mode stay as registered,
/// because the model's calls find their tool by that name, and the rest of the definition was
/// checked a moment ago and must not change after that. The instructions are asked for only once
/// the tool has a function to offer, since they would otherwise describe a tool the model never
/// gets to see.
2026-09-27 16:26:52 +02:00
/// </remarks>
/// <returns>The definition as offered in this request, or null when the tool has nothing to offer or could not say what.</returns>
private async Task < ToolDefinition ?> ResolveAsync ( ToolDefinition definition , IToolImplementation implementation , ToolResolutionContext context , CancellationToken token )
{
ToolFunctionDefinition ? function ;
2026-09-27 21:46:16 +02:00
string instructions ;
2026-09-27 16:26:52 +02:00
try
{
function = await implementation . ResolveFunctionAsync ( definition , context , token );
2026-09-27 21:46:16 +02:00
if ( function is null )
{
this . logger . LogDebug ( "Skipping tool '{ToolId}' because it has nothing to offer in this request." , definition . Id );
return null ;
}
instructions = await implementation . ResolveSystemPromptInstructionsAsync ( definition , context , token );
2026-09-27 16:26:52 +02:00
}
catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
{
throw ;
}
catch ( Exception exception )
{
this . logger . LogError ( exception , "Skipping tool '{ToolId}' because it could not say what it offers in this request." , definition . Id );
return null ;
}
2026-09-27 21:46:16 +02:00
var offeredFunction = this . GetOfferedFunction ( definition , function );
if ( ReferenceEquals ( offeredFunction , definition . Function ) && string . Equals ( instructions , definition . SystemPromptInstructions , StringComparison . Ordinal ))
return definition ;
return definition with
2026-09-27 16:26:52 +02:00
{
2026-09-27 21:46:16 +02:00
Function = offeredFunction ,
SystemPromptInstructions = instructions ,
};
}
2026-09-27 16:26:52 +02:00
2026-09-27 21:46:16 +02:00
/// <summary>
/// The function a tool offers in this request, made of what it answered and what it registered.
/// </summary>
/// <returns>The registered function when the tool offers it unchanged or offers something unusable, otherwise the tailored one with the registered name and strict mode.</returns>
private ToolFunctionDefinition GetOfferedFunction ( ToolDefinition definition , ToolFunctionDefinition function )
{
2026-09-27 16:26:52 +02:00
if ( ReferenceEquals ( function , definition . Function ))
2026-09-27 21:46:16 +02:00
return definition . Function ;
2026-09-27 16:26:52 +02:00
if ( function . Parameters . ValueKind is not JsonValueKind . Object )
{
2026-09-27 21:46:16 +02:00
this . logger . LogWarning ( "Tool '{ToolId}' offered parameters which are not a JSON object schema. Its function is offered as registered instead." , definition . Id );
return definition . Function ;
2026-09-27 16:26:52 +02:00
}
if (! string . Equals ( function . Name , definition . Function . Name , StringComparison . Ordinal ) || function . Strict != definition . Function . Strict )
this . logger . LogWarning ( "Tool '{ToolId}' changed the name or the strict mode of its function for a request. Both stay as registered." , definition . Id );
2026-09-27 21:46:16 +02:00
return function with
2026-09-27 16:26:52 +02:00
{
2026-09-27 21:46:16 +02:00
Name = definition . Function . Name ,
Strict = definition . Function . Strict ,
2026-09-27 16:26:52 +02:00
};
}
2026-09-04 15:48:07 +02:00
}