diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 88d8d949..325b4ce2 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1244,6 +1244,7 @@ public abstract class BaseProvider : IProvider, ISecretId /// The system prompt role to use. /// The request path, relative to the provider base URL. /// Optional additional headers to add. + /// Whether a request which offers tools may ask for one call at a time. False for a provider which rejects the parallel_tool_calls parameter. /// The cancellation token. /// The request DTO type. /// The delta stream line type. @@ -1260,6 +1261,7 @@ public abstract class BaseProvider : IProvider, ISecretId string systemPromptRole = "system", string requestPath = "chat/completions", Action? headersAction = null, + bool mayAskForSequentialToolCalls = true, [EnumeratorCancellation] CancellationToken token = default) where TRequest : ChatCompletionAPIRequest where TDelta : IResponseStreamLine @@ -1298,7 +1300,7 @@ public abstract class BaseProvider : IProvider, ISecretId if (runnableTools.Count > 0) { var adapter = new ChatCompletionToolCallingAdapter(requestFactory, systemPrompt, apiParameters, - runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools, + runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), mayAskForSequentialToolCalls, runnableTools, (requestDto, requestToken) => this.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, headersAction, requestToken), ChatCompletionSourceReader.Read, this.logger); diff --git a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs index 2a225ae8..a50a23c5 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs @@ -184,6 +184,15 @@ public sealed class ProviderHuggingFace : BaseProvider AdditionalApiParameters = apiParameters }; }, + + // + // Hugging Face answers parallel_tool_calls=false with a bad request, "feature + // not currently supported", and its specification of the chat completion does + // not list the parameter at all -- read on 2026-09-23 at + // https://huggingface.co/docs/inference-providers/tasks/chat-completion. Asking + // for it would cost every chat which offers tools its answer: + // + mayAskForSequentialToolCalls: false, token: token)) yield return content; } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs index 1925b1bd..2d425622 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs @@ -16,7 +16,7 @@ namespace AIStudio.Provider.OpenAI; public sealed class ChatCompletionToolCallingAdapter( Func, IList?, Task> requestFactory, TextMessage systemPrompt, IDictionary apiParameters, - IList providerTools, + IList providerTools, bool mayAskForSequentialToolCalls, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools, Func> streamRequestAsync, Func> readSources, @@ -49,9 +49,11 @@ public sealed class ChatCompletionToolCallingAdapter( // // AI Studio runs tool calls one after another, so asking for parallel calls would // only produce work it then has to serialize anyway. Requests without tools omit the - // parameter because some providers reject it then. + // parameter because some providers reject it then. So does every request to a provider + // which rejects the parameter altogether: its models may then ask for several calls at + // once, and the loop works through them one by one, checking the limits per call. // - ParallelToolCalls = requestDtoBase.Tools is null ? null : false, + ParallelToolCalls = requestDtoBase.Tools is null || !mayAskForSequentialToolCalls ? null : false, }; // diff --git a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs index e56ea12f..85cb82d7 100644 --- a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs +++ b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs @@ -86,7 +86,7 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger(StringComparer.Ordinal); var historyTokens = 0; var toolTokens = 0; - var draftTokens = 0; + int draftTokens; try { diff --git a/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs b/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs index 140093bb..146bf63d 100644 --- a/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs +++ b/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text.Json; using AIStudio.Provider; using AIStudio.Provider.OpenAI; @@ -8,7 +9,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace AIStudio.Tests.Provider.ToolCalling; /// -/// Checks which round of a tool calling conversation passes on what its request cost. +/// Checks what a round of a tool calling conversation asks for, and what it passes on. /// /// /// Every round of a tool conversation is a request of its own, and every one of them reports what @@ -17,6 +18,9 @@ namespace AIStudio.Tests.Provider.ToolCalling; /// answer stands. Passing on the last report instead would put the chat at the size of everything /// the tools returned, which is the one number a person watching their context window must not /// see as exact. +/// +/// What a round asks for is one tool call at a time, wherever the provider lets it ask: a provider +/// which rejects the question fails the whole request, so it is not asked at all. /// [TestFixture] public sealed class ChatCompletionToolCallingAdapterTests @@ -75,6 +79,42 @@ public sealed class ChatCompletionToolCallingAdapterTests Assert.That(await Usages(adapter), Is.EqualTo(new[] { 1200 })); } + [Test] + public async Task ARoundWhichOffersToolsAsksForOneCallAtATime() + { + Assert.That(await SentRequest(mayAskForSequentialToolCalls: true, includeTools: true), Does.Contain("\"parallel_tool_calls\":false")); + } + + [Test] + public async Task AProviderWhichRejectsTheQuestionIsNotAskedIt() + { + // + // Hugging Face answers the question with a bad request. Its models may then ask for several + // calls at once, which the loop works through one by one anyway. + // + Assert.That(await SentRequest(mayAskForSequentialToolCalls: false, includeTools: true), Does.Not.Contain("parallel_tool_calls")); + } + + [Test] + public async Task ARoundWithoutToolsDoesNotAskAboutToolCalls() + { + Assert.That(await SentRequest(mayAskForSequentialToolCalls: true, includeTools: false), Does.Not.Contain("parallel_tool_calls")); + } + + /// + /// Runs one round and returns the request it sent, as it goes over the wire. + /// + private static async Task SentRequest(bool mayAskForSequentialToolCalls, bool includeTools) + { + ChatCompletionAPIRequest? sent = null; + var adapter = Adapter(mayAskForSequentialToolCalls, request => sent = request, ["[DONE]"]); + await foreach (var _ in adapter.ExecuteRoundAsync(null, includeTools)) + { + } + + return JsonSerializer.Serialize(sent, ProviderJsonOptions.OPTIONS); + } + /// /// Runs the next round and returns the prompt of every usage it passed on. /// @@ -91,16 +131,27 @@ public sealed class ChatCompletionToolCallingAdapterTests /// /// Builds an adapter whose requests are answered by the given rounds, one after another. /// - private static ChatCompletionToolCallingAdapter Adapter(params string[][] rounds) + private static ChatCompletionToolCallingAdapter Adapter(params string[][] rounds) => Adapter(true, _ => { }, rounds); + + /// + /// Builds an adapter whose requests are answered by the given rounds, and which hands every + /// request it sends to the given observer. + /// + private static ChatCompletionToolCallingAdapter Adapter(bool mayAskForSequentialToolCalls, Action sent, params string[][] rounds) { var nextRound = 0; return new( - (_, _, _) => Task.FromResult(new ChatCompletionAPIRequest("model-a", [], true)), + (_, _, tools) => Task.FromResult(new ChatCompletionAPIRequest("model-a", [], true) { Tools = tools }), new TextMessage("You are a helpful assistant.", "system"), new Dictionary(), [], + mayAskForSequentialToolCalls, [], - (_, token) => Lines(rounds[nextRound++], token), + (request, token) => + { + sent(request); + return Lines(rounds[nextRound++], token); + }, _ => [], NullLogger.Instance); }