Let the chat template of a launcher decide its tools and data sources

This commit is contained in:
Thorsten Sommer committed 2026-09-22 10:37:33 +02:00
1 parent b498c8e2e1
commit 845a967480
5 files changed
+332 -31

No files matched your search

@@ -51,4 +51,20 @@
}
</MudSelect>
<ToolSelectionField Component="Components.CHAT" SelectedToolIds="@this.ToolIds" SelectedToolIdsChanged="@this.SetToolIds" Label="@T("Tools (Optional)")" Help="@T("These tools are preselected when the chat opens. Users can change the selection in the chat, and every tool has to meet the confidence requirements of the provider in use.")"/>
@* A chat template wins over what is chosen here, so the form says so before somebody picks
something which would never take effect: *@
@if (this.SelectedChatTemplate.DataSourceOptions is not null)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@T("The chosen chat template brings data sources of its own, and those win over a selection made here. Only a template can also leave the choice of sources to the AI, which is why it decides this on its own.")
</MudText>
}
<ToolSelectionField Component="Components.CHAT" SelectedToolIds="@this.ToolIds" SelectedToolIdsChanged="@this.SetToolIds" Label="@T("Tools (Optional)")" Help="@T("These tools are preselected when the chat opens. Users can change the selection in the chat, and every tool has to meet the confidence requirements of the provider in use.")"/>
@if (this.SelectedChatTemplate.ToolIds is not null)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">
@T("The chosen chat template brings tools of its own, and those win over a selection made here.")
</MudText>
}
@@ -1,3 +1,5 @@
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
@@ -81,6 +83,19 @@ public partial class DirectChatLauncherForm : MSGComponentBase
/// </summary>
private bool OpensTemporaryChat => string.IsNullOrWhiteSpace(this.WorkspaceName);
/// <summary>
/// The chat template the launcher would open its chat with, as far as it is known here.
/// </summary>
/// <remarks>
/// With "use chat default" chosen, this is whichever template the chat options name right now,
/// and that may well be another one by the time somebody opens the launcher. The form therefore
/// only says what such a template brings along instead of disabling the fields below it: a field
/// which locks itself behind the user's back is worse than a sentence explaining the situation.
/// </remarks>
private ChatTemplate SelectedChatTemplate => string.IsNullOrWhiteSpace(this.ChatTemplateId)
? this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT)
: this.SettingsManager.GetChatTemplateById(this.ChatTemplateId);
private IReadOnlyList<WorkspaceTreeWorkspace> availableWorkspaces = [];
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
@@ -102,10 +102,49 @@ public record ChatTemplate(
{
if(this.Num == uint.MaxValue)
return string.Empty;
return this.SystemPrompt;
}
/// <summary>
/// Decides whose tools a chat started by a launcher begins with.
/// </summary>
/// <remarks>
/// A launcher may name tools itself and may choose a chat template which names tools as well.
/// When both do, the template wins as a whole — the same rule as for the data sources, so that
/// nobody has to remember two of them.
/// </remarks>
/// <param name="chatTemplate">The chat template the launcher opens its chat with.</param>
/// <param name="launcherToolIds">The tools the launcher names itself, or null when it names none.</param>
/// <returns>The tools to start with — null when neither says anything, which leaves the chat default in place — and whether the launcher's own choice was dropped for it.</returns>
public static (IReadOnlyCollection<string>? ToolIds, bool LauncherChoiceDropped) ChooseToolIds(ChatTemplate chatTemplate, IReadOnlyCollection<string>? launcherToolIds)
{
if (chatTemplate.ToolIds is not { } templateToolIds)
return (launcherToolIds, false);
return (templateToolIds, launcherToolIds is not null);
}
/// <summary>
/// Decides whose data source options a chat started by a launcher begins with.
/// </summary>
/// <remarks>
/// The two sides are not equally expressive: a launcher can only ever say "these sources, picked
/// by hand", while a chat template carries the whole options and can also say "let an agent pick
/// them for each message". Mixing them field by field would produce something neither of them
/// asked for, so the template wins as a whole.
/// </remarks>
/// <param name="chatTemplate">The chat template the launcher opens its chat with.</param>
/// <param name="launcherOptions">The options built from the data sources the launcher names, or null when it names none.</param>
/// <returns>The options to start with — null when neither says anything, which leaves the chat default in place — and whether the launcher's own choice was dropped for them.</returns>
public static (DataSourceOptions? Options, bool LauncherChoiceDropped) ChooseDataSourceOptions(ChatTemplate chatTemplate, DataSourceOptions? launcherOptions)
{
if (chatTemplate.DataSourceOptions is not { } templateOptions)
return (launcherOptions, false);
return (templateOptions.CreateCopy(), launcherOptions is not null);
}
public static bool TryParseChatTemplateTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject template)
{
template = NO_CHAT_TEMPLATE;
@@ -45,7 +45,7 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
profile = Profile.NO_PROFILE;
}
var dataSourceOptionsResult = await this.ResolveDataSourceOptionsAsync(providerResult.Provider, launchConfiguration.DataSourceIds);
var dataSourceOptionsResult = await this.ResolveDataSourceOptionsAsync(assistantPlugin, providerResult.Provider, chatTemplate, launchConfiguration.DataSourceIds);
var dataSourceOptions = dataSourceOptionsResult.Options;
if (dataSourceOptions is null)
return new(null, dataSourceOptionsResult.ErrorMessage);
@@ -75,15 +75,21 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
}
}
var toolChoice = ChatTemplate.ChooseToolIds(chatTemplate, launchConfiguration.ToolIds);
if (toolChoice.LauncherChoiceDropped)
logger.LogWarning(
"Assistant plugin '{PluginName}' selects the tools '{LauncherToolIds}', but its chat template '{ChatTemplateName}' names tools of its own. The chat starts with the tools of that template.",
assistantPlugin.Name, string.Join(", ", launchConfiguration.ToolIds!), chatTemplate.GetSafeName());
//
// Only the tools the user could have switched on themselves. A launcher may name one whose
// Only the tools the user could have switched on themselves. Either side may name one whose
// settings are incomplete — an unconfigured web search, say — and starting the chat with it
// enabled would show a state the user cannot produce by hand and cannot fix from the chat.
// Null keeps the chat's own defaults, which is what a launcher without tools wants.
//
var selectedToolIds = launchConfiguration.ToolIds is null
var selectedToolIds = toolChoice.ToolIds is null
? null
: await toolRegistry.FilterSelectableToolIdsAsync(Components.CHAT, launchConfiguration.ToolIds);
: await toolRegistry.FilterSelectableToolIdsAsync(Components.CHAT, toolChoice.ToolIds);
var chatThread = new ChatThread
{
@@ -101,7 +107,12 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
Blocks = chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : chatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
};
return new(new(chatThread, ApplySelectedChatTemplateToComposer: true, PreserveDataSourceOptions: launchConfiguration.DataSourceIds is not null), string.Empty);
//
// Whoever decided these options — the chat template or the launcher — decided them for this
// chat. Without saying so, the chat page would replace them with the chat defaults again:
//
var dataSourcesWereChosen = chatTemplate.DataSourceOptions is not null || launchConfiguration.DataSourceIds is not null;
return new(new(chatThread, ApplySelectedChatTemplateToComposer: true, PreserveDataSourceOptions: dataSourcesWereChosen), string.Empty);
}
private (ProviderSettings Provider, bool IsExplicit, string ErrorMessage) ResolveProvider(Guid? providerId)
@@ -167,64 +178,119 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
: new(chatTemplate, string.Empty);
}
private async Task<(DataSourceOptions? Options, string ErrorMessage)> ResolveDataSourceOptionsAsync(ProviderSettings provider, IReadOnlyList<Guid>? dataSourceIds)
private async Task<(DataSourceOptions? Options, string ErrorMessage)> ResolveDataSourceOptionsAsync(PluginAssistants assistantPlugin, ProviderSettings provider, ChatTemplate chatTemplate, IReadOnlyList<Guid>? launcherDataSourceIds)
{
if (dataSourceIds is null)
//
// The launcher names data sources as plain IDs, and the options around them are always the
// same ones. Building them here turns its choice into the same kind of thing the chat
// template carries, which is what lets one rule decide between the two.
//
DataSourceOptions? launcherOptions = null;
if (launcherDataSourceIds is not null)
{
var standardOptions = settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions;
launcherOptions = new DataSourceOptions
{
DisableDataSources = false,
AutomaticDataSourceSelection = false,
AutomaticValidation = standardOptions.AutomaticValidation,
PreselectedDataSourceIds = launcherDataSourceIds.Select(dataSourceId => dataSourceId.ToString()).ToList(),
};
}
var optionsChoice = ChatTemplate.ChooseDataSourceOptions(chatTemplate, launcherOptions);
if (optionsChoice.LauncherChoiceDropped)
logger.LogWarning(
"Assistant plugin '{PluginName}' selects the data sources '{LauncherDataSourceIds}', but its chat template '{ChatTemplateName}' brings data source options of its own. The chat starts with the data sources of that template.",
assistantPlugin.Name, string.Join(", ", launcherDataSourceIds!), chatTemplate.GetSafeName());
// Neither side says anything, so the chat starts the way it would start on its own:
if (optionsChoice.Options is not { } chosenOptions)
return new(settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(), string.Empty);
return await this.CheckChosenDataSourcesAsync(provider, chosenOptions, chatTemplate.DataSourceOptions is null ? null : chatTemplate);
}
/// <summary>
/// Checks that the chosen data sources exist and may be used with the provider of the chat.
/// </summary>
/// <remarks>
/// Opening a launcher is one click, so a source which is gone or not permitted has to be said
/// out loud instead of being dropped quietly: nobody would see what the chat is missing. Which
/// of the two sides chose the sources changes nothing but the wording — and that wording is the
/// only place where the user learns which of them to go and fix.
/// </remarks>
/// <param name="provider">The provider the launched chat runs with.</param>
/// <param name="chosenOptions">The options the chat is about to start with.</param>
/// <param name="originChatTemplate">The chat template the options came from, or null when the launcher named the sources itself.</param>
/// <returns>The checked options, or null and a message saying why no chat was created.</returns>
private async Task<(DataSourceOptions? Options, string ErrorMessage)> CheckChosenDataSourcesAsync(ProviderSettings provider, DataSourceOptions chosenOptions, ChatTemplate? originChatTemplate)
{
//
// There is nothing to check when data sources are switched off, and nothing to check either
// when an agent picks them: that choice is made per message in the chat, exactly as it is
// for a chat template the user picks by hand.
//
if (chosenOptions.DisableDataSources || chosenOptions.AutomaticDataSourceSelection || chosenOptions.PreselectedDataSourceIds.Count == 0)
return new(chosenOptions, string.Empty);
//
// Deciding which data sources are permitted needs an effective provider. Without one,
// the check below would report every requested source as unavailable, which would hide
// the actual cause from the user:
//
if (provider == ProviderSettings.NONE)
return new(null, TB("The assistant chat launcher selects data sources, but no provider is available for chats. Please choose a default provider for chats first. No chat was created."));
return new(null, originChatTemplate is null
? TB("The assistant chat launcher selects data sources, but no provider is available for chats. Please choose a default provider for chats first. No chat was created.")
: string.Format(TB("The chat template '{0}' selects data sources, but no provider is available for chats. Please choose a default provider for chats first. No chat was created."), originChatTemplate.GetSafeName()));
var requestedDataSources = new List<IDataSource>(dataSourceIds.Count);
foreach (var dataSourceId in dataSourceIds)
var requestedDataSources = new List<IDataSource>(chosenOptions.PreselectedDataSourceIds.Count);
foreach (var dataSourceId in chosenOptions.PreselectedDataSourceIds)
{
// Data sources have no lookup helper in the settings manager, so we match their ids
// the same way the rest of the app does:
var dataSourceIdText = dataSourceId.ToString();
var dataSource = settingsManager.ConfigurationData.DataSources.FirstOrDefault(candidate =>
string.Equals(candidate.Id, dataSourceIdText, StringComparison.OrdinalIgnoreCase));
string.Equals(candidate.Id, dataSourceId, StringComparison.OrdinalIgnoreCase));
if (dataSource is null)
return new(null, string.Format(TB("The assistant chat launcher references data source '{0}', but that data source does not exist."), dataSourceId));
return new(null, originChatTemplate is null
? string.Format(TB("The assistant chat launcher references data source '{0}', but that data source does not exist."), dataSourceId)
: string.Format(TB("The chat template '{0}' references data source '{1}', but that data source does not exist."), originChatTemplate.GetSafeName(), dataSourceId));
requestedDataSources.Add(dataSource);
}
//
// The options the launched chat will run under. We build them here already, because the
// data-source check depends on them: they decide which agent providers take part, and an
// agent with too little confidence makes a data source unavailable.
// The IDs are written back from the sources they resolved to: one of them may be spelled in
// another case than the source itself, and the chat matches its preselection literally.
//
var standardOptions = settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions;
var launchedDataSourceOptions = new DataSourceOptions
{
DisableDataSources = false,
AutomaticDataSourceSelection = false,
AutomaticValidation = standardOptions.AutomaticValidation,
PreselectedDataSourceIds = requestedDataSources.Select(source => source.Id).ToList(),
};
chosenOptions.PreselectedDataSourceIds = requestedDataSources.Select(source => source.Id).ToList();
IReadOnlyList<IDataSource> availableDataSources;
try
{
availableDataSources = await dataSourceService.GetAllowedDataSources(provider, launchedDataSourceOptions, requestedDataSources);
//
// The options the launched chat will run under are what this check runs against: they
// decide which agent providers take part, and an agent with too little confidence makes
// a data source unavailable.
//
availableDataSources = await dataSourceService.GetAllowedDataSources(provider, chosenOptions, requestedDataSources);
}
catch (Exception exception)
{
logger.LogError(exception, "The data sources configured by an assistant chat launcher could not be checked.");
return new(null, TB("The data sources selected by the assistant chat launcher could not be checked. No chat was created."));
logger.LogError(exception, "The data sources an assistant chat launcher would start its chat with could not be checked.");
return new(null, originChatTemplate is null
? TB("The data sources selected by the assistant chat launcher could not be checked. No chat was created.")
: string.Format(TB("The data sources selected by the chat template '{0}' could not be checked. No chat was created."), originChatTemplate.GetSafeName()));
}
var availableSelectedIds = availableDataSources.Select(source => source.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
var unavailableDataSources = requestedDataSources.Where(source => !availableSelectedIds.Contains(source.Id)).Select(source => source.Name).ToList();
if (unavailableDataSources.Count > 0)
return new(null, string.Format(TB("The following data sources selected by the assistant chat launcher are currently unavailable or not permitted for the selected provider: {0}"), string.Join(", ", unavailableDataSources)));
return new(null, originChatTemplate is null
? string.Format(TB("The following data sources selected by the assistant chat launcher are currently unavailable or not permitted for the selected provider: {0}"), string.Join(", ", unavailableDataSources))
: string.Format(TB("The following data sources selected by the chat template '{0}' are currently unavailable or not permitted for the selected provider: {1}"), originChatTemplate.GetSafeName(), string.Join(", ", unavailableDataSources)));
return new(launchedDataSourceOptions, string.Empty);
return new(chosenOptions, string.Empty);
}
}