Added tool calling support (#731)

Co-authored-by: krut_ni <nils.kruthoff@dlr.de>
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-04 15:48:07 +02:00
1 parent b00c3f9ab3
commit 4d8d30e15e
266 files changed
+11186 -740

No files matched your search

@@ -0,0 +1,23 @@
using AIStudio.Provider;
namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// <summary>
/// Drives a conversation in which a model may call tools before it answers.
/// </summary>
/// <remarks>
/// Resolved through dependency injection so that a different harness can take over later without
/// touching the providers: an agent mode needs more than "ask, execute, ask again", but it speaks
/// to providers through the same adapters.
/// </remarks>
public interface IToolCallingLoop
{
/// <summary>
/// Runs the conversation until the model answers, the limits are reached, or the request fails.
/// </summary>
/// <param name="adapter">The adapter for the provider API in use.</param>
/// <param name="context">The chat, tools, and UI state this run belongs to.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The model's final answer, with the sources the tools contributed.</returns>
public IAsyncEnumerable<ContentStreamChunk> RunAsync(IToolCallingProviderAdapter adapter, ToolCallingLoopContext context, CancellationToken token = default);
}
@@ -0,0 +1,52 @@
namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// <summary>
/// Translates between the tool calling loop and one provider API's request and response shapes.
/// </summary>
/// <remarks>
/// The loop itself is the same for every provider: ask, execute what was asked for, ask again.
/// What differs is the wire format — Chat Completions puts tool calls in a message and takes
/// results as tool messages, the Responses API uses function call items correlated by call ID,
/// and Anthropic uses content blocks. An adapter hides exactly that difference.<br/><br/>
/// An adapter is stateful and belongs to one streaming call: it accumulates the conversation
/// the next round has to see. Do not share one across calls.
/// </remarks>
public interface IToolCallingProviderAdapter
{
/// <summary>
/// Executes one non-streamed round and returns what the model answered.
/// </summary>
/// <param name="finalResponseInstruction">
/// When set, the instruction telling the model that no more tools are available. The adapter
/// appends it to the system prompt for this round only.
/// </param>
/// <param name="includeTools">Whether the tools may be offered in this round.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>
/// The round's outcome, or null when the request failed. Null ends the loop without an error
/// message because the adapter has already told the user what went wrong.
/// </returns>
public Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default);
/// <summary>
/// Records the model's turn from the round just executed, so that the next round sees it.
/// </summary>
/// <remarks>
/// Called before any tool result of that round is recorded. What exactly has to be kept is
/// the adapter's business: Chat Completions needs the assistant message with its tool calls,
/// while the Responses API needs every output item, including reasoning items, or it refuses
/// to continue.
/// </remarks>
public void RecordAssistantTurn();
/// <summary>
/// Records the result of one tool call so that the next round sees it.
/// </summary>
/// <param name="callId">The ID of the call this result belongs to.</param>
/// <param name="content">The result as the model should see it.</param>
/// <param name="isError">
/// Whether the tool failed instead of returning a result. Only some APIs can express this;
/// the others carry the failure in the content, which is where it has to be legible anyway.
/// </param>
public void RecordToolResult(string callId, string content, bool isError = false);
}
@@ -0,0 +1,171 @@
using System.Runtime.CompilerServices;
using AIStudio.Provider;
namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// <summary>
/// The sequential tool calling loop: ask the model, run what it asked for, ask again.
/// </summary>
/// <remarks>
/// One implementation for every provider API. Everything that differs between Chat Completions,
/// the Responses API, and Anthropic's messages lives in the adapter, so adding a provider means
/// writing an adapter, not another loop.<br/><br/>
/// Tool calls run one after another. A tool may of course work concurrently inside itself, as the
/// web search does when it loads several pages.
/// </remarks>
public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCallingLoop
{
private const string NO_ANSWER_AFTER_TOOL_CALL = "The model completed the tool call but did not return a final answer.";
private const string NO_ANSWER_AFTER_LIMIT = "The model did not return a final answer after completing the available tool calls.";
/// <inheritdoc />
public async IAsyncEnumerable<ContentStreamChunk> RunAsync(
IToolCallingProviderAdapter adapter,
ToolCallingLoopContext context,
[EnumeratorCancellation] CancellationToken token = default)
{
var toolCallCount = 0;
var toolResultCharacterCount = 0L;
var toolSources = new List<Source>();
while (true)
{
//
// Both limits end the conversation the same way: the model is told that it has no
// tools left and is asked for its best answer from what it already has.
//
var finalResponseInstruction = ToolSelectionRules.GetToolCallsUnavailableInstruction(toolCallCount, toolResultCharacterCount);
var finalResponseRequired = finalResponseInstruction is not null;
var round = await adapter.ExecuteRoundAsync(finalResponseInstruction, !finalResponseRequired, token);
if (round is null)
{
await context.ResetToolRuntimeStatusAsync();
yield break;
}
toolSources.MergeSources(round.Sources);
//
// A call without an ID cannot be answered: the provider correlates the result by that
// ID, and inventing one would have the next request rejected. Nothing can be salvaged
// from this round, so the conversation ends here.
//
if (round.Calls.Any(call => string.IsNullOrWhiteSpace(call.CallId)))
{
toolCallCount++;
var (unanswerableContent, unanswerableTrace, _, _) = context.ToolExecutor.CreateInvalidToolCallResult(string.Empty, toolCallCount);
await context.AddToolInvocationAsync(unanswerableTrace);
await context.ResetToolRuntimeStatusAsync();
yield return new ContentStreamChunk(unanswerableContent, [..toolSources]);
yield break;
}
if (finalResponseRequired)
{
await context.ResetToolRuntimeStatusAsync();
yield return new ContentStreamChunk(
string.IsNullOrWhiteSpace(round.TextOutput) ? NO_ANSWER_AFTER_LIMIT : round.TextOutput,
[..toolSources]);
yield break;
}
if (round.Calls.Count is 0)
{
await context.ResetToolRuntimeStatusAsync();
if (!string.IsNullOrWhiteSpace(round.TextOutput))
{
yield return new ContentStreamChunk(round.TextOutput, [..toolSources]);
yield break;
}
if (toolCallCount > 0)
{
yield return new ContentStreamChunk(NO_ANSWER_AFTER_TOOL_CALL, [..toolSources]);
yield break;
}
//
// Neither text nor a tool call on the very first round: there is nothing to show
// and nothing to run. Staying silent would look like a hung request, so this is
// reported as what it is — a provider that did not answer.
//
logger.LogError(
"The tool calling response contained neither text nor tool calls. ProviderInstanceName={ProviderInstanceName}, ProviderType={ProviderType}, ModelId={ModelId}",
context.ProviderInstanceName,
context.ProviderType,
context.ModelId);
throw ToolCallingMessages.InvalidToolCallingResponse(context.ProviderInstanceName);
}
try
{
var validToolNames = round.Calls
.Where(call => call.IsValid)
.Select(call => GetDisplayName(context, call.ToolName))
.ToList();
if (validToolNames.Count > 0)
await context.ShowToolRuntimeStatusAsync(validToolNames);
// The model's turn has to be recorded before its results, or the provider sees
// results for a turn it does not know about:
adapter.RecordAssistantTurn();
foreach (var call in round.Calls)
{
if (!call.IsValid)
{
toolCallCount++;
var (invalidContent, invalidTrace, _, _) = context.ToolExecutor.CreateInvalidToolCallResult(call.CallId, toolCallCount);
toolResultCharacterCount += invalidContent.Length;
await context.AddToolInvocationAsync(invalidTrace);
adapter.RecordToolResult(call.CallId, invalidContent, isError: true);
continue;
}
//
// The limits are checked again per call, because one round may ask for
// several tools and the earlier ones can exhaust the budget:
//
var callsUnavailableInstruction = ToolSelectionRules.GetToolCallsUnavailableInstruction(toolCallCount, toolResultCharacterCount);
if (callsUnavailableInstruction is not null)
{
adapter.RecordToolResult(call.CallId, callsUnavailableInstruction);
continue;
}
toolCallCount++;
var (toolContent, trace, requiredProviderConfidence, sources) = await context.ToolExecutor.ExecuteAsync(
call.CallId,
call.ToolName,
call.ArgumentsJson,
context.RunnableTools,
context.Provider,
toolCallCount,
token);
toolResultCharacterCount += toolContent.Length;
context.ChatThread.RequireProviderConfidence(requiredProviderConfidence);
toolSources.MergeSources(sources);
await context.AddToolInvocationAsync(trace);
// A blocked call counts as a failure towards the model as much as an errored
// one does: in both cases it did not get the data it asked for.
adapter.RecordToolResult(call.CallId, toolContent, trace.Status is not ToolInvocationTraceStatus.SUCCESS);
}
}
finally
{
await context.ResetToolRuntimeStatusAsync();
}
}
}
private static string GetDisplayName(ToolCallingLoopContext context, string toolName) => context.RunnableTools
.FirstOrDefault(tool => tool.Definition.Function.Name.Equals(toolName, StringComparison.Ordinal))
.Implementation?.GetDisplayName() ?? toolName;
}
@@ -0,0 +1,93 @@
using AIStudio.Chat;
using AIStudio.Provider;
namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// <summary>
/// Everything one run of the tool calling loop needs besides its provider adapter.
/// </summary>
public sealed class ToolCallingLoopContext
{
/// <summary>
/// The chat the loop runs for. Tool results may raise its required provider confidence.
/// </summary>
public required ChatThread ChatThread { get; init; }
/// <summary>
/// The tools the model may call in this run.
/// </summary>
public required IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> RunnableTools { get; init; }
public required ToolExecutor ToolExecutor { get; init; }
/// <summary>
/// The provider running the conversation, needed to judge what a tool may return to it.
/// </summary>
public required IProvider Provider { get; init; }
/// <summary>
/// The assistant message being built, or null when there is none to update.
/// </summary>
/// <remarks>
/// The loop writes the tool traces and the live status into this instance, which is already
/// part of the chat thread. That is how the UI learns about a running tool without the loop
/// having to yield anything.
/// </remarks>
public ContentText? CurrentAssistantContent { get; init; }
public required string ProviderInstanceName { get; init; }
public required LLMProviders ProviderType { get; init; }
public required string ModelId { get; init; }
/// <summary>
/// Records one tool invocation for the UI.
/// </summary>
/// <remarks>
/// Tells the UI right away, so a finished call shows up while the next one is still running.
/// Waiting for the round to end would leave the user watching a list that lags behind what the
/// model is doing.
/// </remarks>
public async Task AddToolInvocationAsync(ToolInvocationTrace trace)
{
if (this.CurrentAssistantContent is null)
return;
this.CurrentAssistantContent.ToolInvocations.Add(trace);
await this.CurrentAssistantContent.StreamingEvent();
}
/// <summary>
/// Tells the UI that the named tools are running.
/// </summary>
public async Task ShowToolRuntimeStatusAsync(IEnumerable<string> toolNames)
{
if (this.CurrentAssistantContent is null)
return;
this.CurrentAssistantContent.ToolRuntimeStatus = new ToolRuntimeStatus
{
IsRunning = true,
ToolNames = [.. toolNames],
};
await this.CurrentAssistantContent.StreamingEvent();
}
/// <summary>
/// Clears the running-tool status.
/// </summary>
/// <remarks>
/// Must happen on every path leaving a round, including the failing ones: a status left
/// behind tells the user a tool is still running when nothing is.
/// </remarks>
public async Task ResetToolRuntimeStatusAsync()
{
if (this.CurrentAssistantContent is null)
return;
this.CurrentAssistantContent.ToolRuntimeStatus = new();
await this.CurrentAssistantContent.StreamingEvent();
}
}
@@ -0,0 +1,36 @@
using AIStudio.Provider;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// <summary>
/// The messages the tool calling harness shows the user.
/// </summary>
/// <remarks>
/// Shared between the loop and its adapters: an unusable response looks the same to the user
/// whether the loop or the adapter noticed it, and one wording means one translation.
/// </remarks>
public static class ToolCallingMessages
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ToolCallingMessages).Namespace, nameof(ToolCallingMessages));
/// <summary>
/// Builds the exception for a response that cannot be used to continue.
/// </summary>
/// <param name="providerInstanceName">The provider instance the user configured.</param>
public static ProviderRequestException InvalidToolCallingResponse(string providerInstanceName) => new(
ProviderRequestFailureReason.NONE,
string.Format(TB("The provider '{0}' returned an invalid tool calling response. Check the provider's tool calling configuration and see the logs for details."), providerInstanceName));
/// <summary>
/// Tells the user that a tool round could not be requested at all.
/// </summary>
/// <remarks>
/// Shared by every adapter: the status code is what the user can act on, and the wording
/// should not differ by provider API.
/// </remarks>
/// <param name="statusCode">The status code the provider answered with.</param>
public static async Task SendToolCallingRequestFailedAsync(int statusCode) => await MessageBus.INSTANCE.SendError(new(
Icons.Material.Filled.Build,
string.Format(TB("The tool calling request failed with status code {0}. See the logs for details."), statusCode)));
}
@@ -0,0 +1,17 @@
namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// <summary>
/// One tool call a model requested.
/// </summary>
/// <remarks>
/// Invalid calls are carried through rather than dropped: the model has to learn that its call
/// was rejected, otherwise it waits for a result that never arrives.
/// </remarks>
/// <param name="CallId">
/// The ID correlating this call with its result. Empty when the provider did not supply one and
/// the adapter cannot invent one, which makes the call unanswerable.
/// </param>
/// <param name="ToolName">The name of the tool the model asked for.</param>
/// <param name="ArgumentsJson">The arguments as the model wrote them, to be treated as untrusted input.</param>
/// <param name="IsValid">Whether name and arguments are usable at all.</param>
public sealed record ToolCallingRequestedCall(string CallId, string ToolName, string ArgumentsJson, bool IsValid);
@@ -0,0 +1,10 @@
namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// <summary>
/// The outcome of one non-streamed round of a tool calling conversation, in a shape that no
/// longer depends on the provider API it came from.
/// </summary>
/// <param name="TextOutput">The text the model produced, empty when it only requested tool calls.</param>
/// <param name="Calls">The tool calls the model requested, empty when it answered instead.</param>
/// <param name="Sources">Sources the provider itself attached, such as those of a provider-native web search.</param>
public sealed record ToolCallingRound(string TextOutput, IReadOnlyList<ToolCallingRequestedCall> Calls, IReadOnlyList<ISource> Sources);