mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 03:53:36 +00:00
Let the chat template of a launcher decide its tools and data sources
This commit is contained in:
parent
b498c8e2e1
commit
845a967480
@ -51,4 +51,20 @@
|
|||||||
}
|
}
|
||||||
</MudSelect>
|
</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;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace AIStudio.Components;
|
namespace AIStudio.Components;
|
||||||
@ -81,6 +83,19 @@ public partial class DirectChatLauncherForm : MSGComponentBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private bool OpensTemporaryChat => string.IsNullOrWhiteSpace(this.WorkspaceName);
|
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 IReadOnlyList<WorkspaceTreeWorkspace> availableWorkspaces = [];
|
||||||
|
|
||||||
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
|
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
|
||||||
|
|||||||
@ -102,10 +102,49 @@ public record ChatTemplate(
|
|||||||
{
|
{
|
||||||
if(this.Num == uint.MaxValue)
|
if(this.Num == uint.MaxValue)
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
return this.SystemPrompt;
|
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)
|
public static bool TryParseChatTemplateTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject template)
|
||||||
{
|
{
|
||||||
template = NO_CHAT_TEMPLATE;
|
template = NO_CHAT_TEMPLATE;
|
||||||
|
|||||||
@ -45,7 +45,7 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
|
|||||||
profile = Profile.NO_PROFILE;
|
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;
|
var dataSourceOptions = dataSourceOptionsResult.Options;
|
||||||
if (dataSourceOptions is null)
|
if (dataSourceOptions is null)
|
||||||
return new(null, dataSourceOptionsResult.ErrorMessage);
|
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
|
// 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.
|
// 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.
|
// 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
|
? null
|
||||||
: await toolRegistry.FilterSelectableToolIdsAsync(Components.CHAT, launchConfiguration.ToolIds);
|
: await toolRegistry.FilterSelectableToolIdsAsync(Components.CHAT, toolChoice.ToolIds);
|
||||||
|
|
||||||
var chatThread = new ChatThread
|
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(),
|
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)
|
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);
|
: 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 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,
|
// 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 check below would report every requested source as unavailable, which would hide
|
||||||
// the actual cause from the user:
|
// the actual cause from the user:
|
||||||
//
|
//
|
||||||
if (provider == ProviderSettings.NONE)
|
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);
|
var requestedDataSources = new List<IDataSource>(chosenOptions.PreselectedDataSourceIds.Count);
|
||||||
foreach (var dataSourceId in dataSourceIds)
|
foreach (var dataSourceId in chosenOptions.PreselectedDataSourceIds)
|
||||||
{
|
{
|
||||||
// Data sources have no lookup helper in the settings manager, so we match their ids
|
// Data sources have no lookup helper in the settings manager, so we match their ids
|
||||||
// the same way the rest of the app does:
|
// the same way the rest of the app does:
|
||||||
var dataSourceIdText = dataSourceId.ToString();
|
|
||||||
var dataSource = settingsManager.ConfigurationData.DataSources.FirstOrDefault(candidate =>
|
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)
|
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);
|
requestedDataSources.Add(dataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// The options the launched chat will run under. We build them here already, because the
|
// The IDs are written back from the sources they resolved to: one of them may be spelled in
|
||||||
// data-source check depends on them: they decide which agent providers take part, and an
|
// another case than the source itself, and the chat matches its preselection literally.
|
||||||
// agent with too little confidence makes a data source unavailable.
|
|
||||||
//
|
//
|
||||||
var standardOptions = settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions;
|
chosenOptions.PreselectedDataSourceIds = requestedDataSources.Select(source => source.Id).ToList();
|
||||||
var launchedDataSourceOptions = new DataSourceOptions
|
|
||||||
{
|
|
||||||
DisableDataSources = false,
|
|
||||||
AutomaticDataSourceSelection = false,
|
|
||||||
AutomaticValidation = standardOptions.AutomaticValidation,
|
|
||||||
PreselectedDataSourceIds = requestedDataSources.Select(source => source.Id).ToList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<IDataSource> availableDataSources;
|
IReadOnlyList<IDataSource> availableDataSources;
|
||||||
try
|
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)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogError(exception, "The data sources configured by an assistant chat launcher could not be checked.");
|
logger.LogError(exception, "The data sources an assistant chat launcher would start its chat with 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."));
|
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 availableSelectedIds = availableDataSources.Select(source => source.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var unavailableDataSources = requestedDataSources.Where(source => !availableSelectedIds.Contains(source.Id)).Select(source => source.Name).ToList();
|
var unavailableDataSources = requestedDataSources.Where(source => !availableSelectedIds.Contains(source.Id)).Select(source => source.Name).ToList();
|
||||||
if (unavailableDataSources.Count > 0)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
165
app/Tests/Settings/ChatTemplatePrecedenceTests.cs
Normal file
165
app/Tests/Settings/ChatTemplatePrecedenceTests.cs
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
using AIStudio.Settings;
|
||||||
|
using AIStudio.Settings.DataModel;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks who decides the tools and data sources when a direct chat launcher and the chat template
|
||||||
|
/// it opens its chat with both name some.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Either side can be filled in without knowing about the other, so all four combinations happen.
|
||||||
|
/// The rule is deliberately the same for tools and for data sources: the chat template wins as a
|
||||||
|
/// whole, because it is the only one of the two which can also leave the choice of sources to an
|
||||||
|
/// agent, and a field-by-field mix of both would be something neither of them asked for.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class ChatTemplatePrecedenceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void WhenNeitherSideSaysAnythingTheChatDefaultsStay()
|
||||||
|
{
|
||||||
|
var toolChoice = ChatTemplate.ChooseToolIds(NewTemplate(), null);
|
||||||
|
var optionsChoice = ChatTemplate.ChooseDataSourceOptions(NewTemplate(), null);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(toolChoice.ToolIds, Is.Null, "Nobody named any tool, so the chat has to keep using the tools of its own default.");
|
||||||
|
Assert.That(toolChoice.LauncherChoiceDropped, Is.False);
|
||||||
|
Assert.That(optionsChoice.Options, Is.Null, "Nobody named any data source, so the chat has to keep using its own default options.");
|
||||||
|
Assert.That(optionsChoice.LauncherChoiceDropped, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ALauncherAloneDecidesForItself()
|
||||||
|
{
|
||||||
|
var template = NewTemplate();
|
||||||
|
var launcherOptions = NewLauncherOptions("11111111-1111-1111-1111-111111111111");
|
||||||
|
|
||||||
|
var toolChoice = ChatTemplate.ChooseToolIds(template, new[] { "web_search" });
|
||||||
|
var optionsChoice = ChatTemplate.ChooseDataSourceOptions(template, launcherOptions);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(toolChoice.ToolIds, Is.EquivalentTo(new[] { "web_search" }));
|
||||||
|
Assert.That(toolChoice.LauncherChoiceDropped, Is.False, "Nothing was dropped here, so nothing may be reported as dropped either.");
|
||||||
|
Assert.That(optionsChoice.Options, Is.SameAs(launcherOptions));
|
||||||
|
Assert.That(optionsChoice.LauncherChoiceDropped, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ATemplateAloneDecidesForItself()
|
||||||
|
{
|
||||||
|
var template = NewTemplate() with
|
||||||
|
{
|
||||||
|
ToolIds = ["read_web_page"],
|
||||||
|
DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = ["22222222-2222-2222-2222-222222222222"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
var toolChoice = ChatTemplate.ChooseToolIds(template, null);
|
||||||
|
var optionsChoice = ChatTemplate.ChooseDataSourceOptions(template, null);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(toolChoice.ToolIds, Is.EquivalentTo(new[] { "read_web_page" }));
|
||||||
|
Assert.That(toolChoice.LauncherChoiceDropped, Is.False);
|
||||||
|
Assert.That(optionsChoice.Options!.PreselectedDataSourceIds, Is.EqualTo(new[] { "22222222-2222-2222-2222-222222222222" }));
|
||||||
|
Assert.That(optionsChoice.LauncherChoiceDropped, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenBothSidesSpeakTheTemplateWinsAndTheLossIsReported()
|
||||||
|
{
|
||||||
|
var template = NewTemplate() with
|
||||||
|
{
|
||||||
|
ToolIds = ["read_web_page"],
|
||||||
|
DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = ["22222222-2222-2222-2222-222222222222"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
var toolChoice = ChatTemplate.ChooseToolIds(template, new[] { "web_search" });
|
||||||
|
var optionsChoice = ChatTemplate.ChooseDataSourceOptions(template, NewLauncherOptions("11111111-1111-1111-1111-111111111111"));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(toolChoice.ToolIds, Is.EquivalentTo(new[] { "read_web_page" }));
|
||||||
|
Assert.That(toolChoice.LauncherChoiceDropped, Is.True, "The tools of the launcher are gone, and only this flag can make the log say so.");
|
||||||
|
Assert.That(optionsChoice.Options!.PreselectedDataSourceIds, Is.EqualTo(new[] { "22222222-2222-2222-2222-222222222222" }));
|
||||||
|
Assert.That(optionsChoice.LauncherChoiceDropped, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ATemplateWhichWantsNoToolsWinsJustTheSame()
|
||||||
|
{
|
||||||
|
var template = NewTemplate() with { ToolIds = [] };
|
||||||
|
|
||||||
|
var toolChoice = ChatTemplate.ChooseToolIds(template, new[] { "web_search" });
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(toolChoice.ToolIds, Is.Empty, "An empty selection is the statement that this template wants no tools, which is as much of a statement as naming one.");
|
||||||
|
Assert.That(toolChoice.LauncherChoiceDropped, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TheAgenticSelectionOfATemplateSurvivesALauncherWithItsOwnSources()
|
||||||
|
{
|
||||||
|
var template = NewTemplate() with
|
||||||
|
{
|
||||||
|
DataSourceOptions = new() { DisableDataSources = false, AutomaticDataSourceSelection = true, PreselectedDataSourceIds = [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
var optionsChoice = ChatTemplate.ChooseDataSourceOptions(template, NewLauncherOptions("11111111-1111-1111-1111-111111111111"));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(optionsChoice.Options!.AutomaticDataSourceSelection, Is.True, "Letting an agent pick the sources is the one thing a launcher cannot express, so it is exactly what must not be overwritten by one.");
|
||||||
|
Assert.That(optionsChoice.Options.PreselectedDataSourceIds, Is.Empty);
|
||||||
|
Assert.That(optionsChoice.LauncherChoiceDropped, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TheChosenOptionsAreACopyRatherThanTheOnesOfTheTemplate()
|
||||||
|
{
|
||||||
|
var template = NewTemplate() with
|
||||||
|
{
|
||||||
|
DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = ["22222222-2222-2222-2222-222222222222"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
var optionsChoice = ChatTemplate.ChooseDataSourceOptions(template, null);
|
||||||
|
optionsChoice.Options!.PreselectedDataSourceIds.Clear();
|
||||||
|
|
||||||
|
Assert.That(template.DataSourceOptions!.PreselectedDataSourceIds, Is.Not.Empty, "The launched chat goes on to change these options, and the template is a setting of the user which must not change with it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A template which says nothing about tools or data sources.
|
||||||
|
/// </summary>
|
||||||
|
private static ChatTemplate NewTemplate() => new()
|
||||||
|
{
|
||||||
|
Num = 1,
|
||||||
|
Id = "33333333-3333-3333-3333-333333333333",
|
||||||
|
Name = "Intranet Research",
|
||||||
|
SystemPrompt = "You are a research assistant.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The options a launcher which names data sources ends up with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A launcher has no switches of its own: it names sources, and the rest is always this. Which
|
||||||
|
/// is the whole reason the chat template wins whenever both of them speak.
|
||||||
|
/// </remarks>
|
||||||
|
private static DataSourceOptions NewLauncherOptions(params string[] dataSourceIds) => new()
|
||||||
|
{
|
||||||
|
DisableDataSources = false,
|
||||||
|
AutomaticDataSourceSelection = false,
|
||||||
|
AutomaticValidation = false,
|
||||||
|
PreselectedDataSourceIds = [..dataSourceIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user