Let tools tailor what they offer to each request

This commit is contained in:
Thorsten Sommer committed 2026-09-24 14:51:27 +02:00
1 parent 38d4520e80
commit 51e7f621bb
12 files changed
+452 -126

No files matched your search

@@ -10,6 +10,8 @@ namespace AIStudio.Tools.ToolCallingSystem;
/// them all the same way.<br/><br/>
/// A source is asked once while the registry is being built. Definitions do not change while the
/// app runs; a plugin that was loaded later needs the registry rebuilt, not the source re-read.
/// What a tool offers in a single request may still differ from its definition: the tool tailors
/// its function to the request then, see IToolImplementation.ResolveFunctionAsync.
/// </remarks>
public interface IToolDefinitionSource
{
@@ -19,6 +19,31 @@ public interface IToolImplementation
/// </remarks>
public ToolDefinition GetDefinition();
/// <summary>
/// The function this tool offers the model in the request being prepared, or null when it has
/// nothing to offer there.
/// </summary>
/// <remarks>
/// A definition is registered once, but some tools cannot say what they offer until they know
/// the request. Semantic Search describes the data sources of the chat, and only those the
/// provider may search; without any of them, it has nothing to offer, and the model should not
/// learn about a tool which can only come back empty. Most tools offer the same function every
/// time, which is what this returns unless a tool says otherwise.<br/><br/>
/// Asked for every request, after every check of ToolRegistry has passed, so it only decides
/// what an allowed tool offers, never whether it is allowed. For the same reason, only the
/// description and the parameters of what comes back are used: the function keeps the name and
/// the strict mode it was registered with, and the definition everything else. A tool which
/// throws is left out of the request.<br/><br/>
/// Keep the result stable while the chat stays the same, down to the order of what it lists:
/// the providers cache a request from its beginning, and the tools are part of that beginning.
/// </remarks>
/// <param name="definition">The definition as registered.</param>
/// <param name="context">The request being prepared.</param>
/// <param name="token">The cancellation token of the request.</param>
/// <returns>The function to offer, or null to leave the tool out of this request.</returns>
public ValueTask<ToolFunctionDefinition?> ResolveFunctionAsync(ToolDefinition definition, ToolResolutionContext context, CancellationToken token = default) =>
ValueTask.FromResult<ToolFunctionDefinition?>(definition.Function);
public string Icon => Icons.Material.Filled.Build;
public IReadOnlySet<string> SensitiveTraceArgumentNames { get; }
@@ -2,7 +2,14 @@ using AIStudio.Provider;
namespace AIStudio.Tools.ToolCallingSystem;
public sealed class ToolDefinition
/// <summary>
/// What a tool is: what the model may call, which settings it needs, and where it may be used.
/// </summary>
/// <remarks>
/// A record, so that the registry can hand out a definition whose function a tool tailored to one
/// request while everything else stays as registered, see IToolImplementation.ResolveFunctionAsync.
/// </remarks>
public sealed record ToolDefinition
{
public int SchemaVersion { get; init; } = 1;
@@ -2,7 +2,14 @@ using System.Text.Json;
namespace AIStudio.Tools.ToolCallingSystem;
public sealed class ToolFunctionDefinition
/// <summary>
/// The function a tool offers the model: its name, what it does, and the arguments it takes.
/// </summary>
/// <remarks>
/// A record, so that a tool tailoring its function to a request changes only what it has to, e.g.
/// definition.Function with { DescriptionForLLM = … }, see IToolImplementation.ResolveFunctionAsync.
/// </remarks>
public sealed record ToolFunctionDefinition
{
public string Name { get; init; } = string.Empty;
@@ -339,14 +339,27 @@ public sealed class ToolRegistry
return items;
}
/// <summary>
/// The tools a request offers the model, each with the function it offers in this request.
/// </summary>
/// <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
/// caller to gate tools on capabilities that differed from the ones the availability check saw.
/// caller to gate tools on capabilities that differed from the ones the availability check saw.<br/><br/>
/// The candidates are the selected tools 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.
/// </remarks>
public async Task<IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)>> GetRunnableToolsAsync(AIStudio.Settings.Provider provider,
Components component, IEnumerable<string> selectedToolIds, ConfidenceLevel providerConfidence, bool mayRunTools)
/// <param name="context">The request being prepared.</param>
/// <param name="selectedToolIds">The tools selected for the request.</param>
/// <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)
{
var provider = context.Provider;
var component = context.Component;
var providerConfidence = context.ProviderConfidence;
if (!this.settingsManager.AreToolsEnabled())
{
this.logger.LogDebug("Tool calling is skipped because tools are disabled by managed configuration.");
@@ -374,7 +387,10 @@ public sealed class ToolRegistry
var selectedToolIdSet = ToolSelectionRules.NormalizeSelection(selectedToolIds);
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)));
var definitions = this.GetDefinitionsForComponent(component).Where(x => selectedToolIdSet.Contains(x.Id)).ToList();
var definitions = this.GetDefinitionsForComponent(component)
.Where(x => x.Activation is ToolActivation.CONTEXT || selectedToolIdSet.Contains(x.Id))
.ToList();
var result = new List<(ToolDefinition, IToolImplementation)>(definitions.Count);
foreach (var definition in definitions)
{
@@ -385,7 +401,9 @@ public sealed class ToolRegistry
switch (check)
{
case { BlockReason: ToolOfferBlockReason.NONE, Implementation: { } implementation }:
result.Add((definition, implementation));
if (await this.ResolveAsync(definition, implementation, context, token) is { } offeredDefinition)
result.Add((offeredDefinition, implementation));
break;
case { BlockReason: ToolOfferBlockReason.TOOL_SWITCHED_OFF }:
@@ -470,4 +488,58 @@ public sealed class ToolRegistry
return new(ToolOfferBlockReason.NONE, implementation, minimumConfidence);
}
/// <summary>
/// Asks a tool which passed every check what it offers in this request.
/// </summary>
/// <remarks>
/// Only the description and the parameters of the answer are taken. 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.
/// </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;
try
{
function = await implementation.ResolveFunctionAsync(definition, context, token);
}
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;
}
if (function is null)
{
this.logger.LogDebug("Skipping tool '{ToolId}' because it has nothing to offer in this request.", definition.Id);
return null;
}
if (ReferenceEquals(function, definition.Function))
return definition;
if (function.Parameters.ValueKind is not JsonValueKind.Object)
{
this.logger.LogWarning("Tool '{ToolId}' offered parameters which are not a JSON object schema. It is offered as registered instead.", definition.Id);
return definition;
}
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);
return definition with
{
Function = function with
{
Name = definition.Function.Name,
Strict = definition.Function.Strict,
},
};
}
}
@@ -0,0 +1,35 @@
using AIStudio.Chat;
using AIStudio.Provider;
namespace AIStudio.Tools.ToolCallingSystem;
/// <summary>
/// The request a tool is being prepared for.
/// </summary>
/// <remarks>
/// What a tool may look at when it tailors its function to a request, see
/// IToolImplementation.ResolveFunctionAsync. Semantic Search, for instance, reads the data sources
/// of the chat and describes exactly those which this provider may search.
/// </remarks>
public sealed class ToolResolutionContext
{
/// <summary>
/// The provider the request goes to, with the expert settings of the user.
/// </summary>
public required AIStudio.Settings.Provider Provider { get; init; }
/// <summary>
/// The part of the app the request comes from.
/// </summary>
public required Components Component { get; init; }
/// <summary>
/// How much the provider is trusted.
/// </summary>
public required ConfidenceLevel ProviderConfidence { get; init; }
/// <summary>
/// The chat the request continues.
/// </summary>
public required ChatThread ChatThread { get; init; }
}