The LLM answers now with a final message if the maximum of tool calls is reached.

This commit is contained in:
Peer Schütt 2026-07-17 10:53:05 +02:00
parent 43665b2caa
commit c43820756e
4 changed files with 65 additions and 35 deletions

View File

@ -1044,7 +1044,17 @@ public abstract class BaseProvider : IProvider, ISecretId
var toolCallCount = 0;
while (true)
{
ChatCompletionAPIRequest requestDtoBase = await requestFactory(systemPrompt, apiParameters, providerTools);
var finalResponseRequired = toolCallCount >= ToolSelectionRules.MAX_TOOL_CALLS;
var requestSystemPrompt = finalResponseRequired
? systemPrompt with
{
Content = $"{systemPrompt.Content}{Environment.NewLine}{Environment.NewLine}{ToolSelectionRules.GetMaxToolCallsFinalResponseInstruction()}",
}
: systemPrompt;
ChatCompletionAPIRequest requestDtoBase = await requestFactory(
requestSystemPrompt,
apiParameters,
finalResponseRequired ? null : providerTools);
var requestDto = requestDtoBase with
{
Messages = [..requestDtoBase.Messages, ..internalMessages],
@ -1070,6 +1080,17 @@ public abstract class BaseProvider : IProvider, ISecretId
yield break;
}
if (finalResponseRequired)
{
await ResetToolRuntimeStatusAsync();
if (!string.IsNullOrWhiteSpace(responseMessage.Content))
yield return new ContentStreamChunk(responseMessage.Content, []);
else
yield return new ContentStreamChunk("The model did not return a final answer after completing the available tool calls.", []);
yield break;
}
await ShowToolRuntimeStatusAsync(toolCalls
.Select(x => runnableTools.FirstOrDefault(tool => tool.Definition.Function.Name.Equals(x.Function.Name, StringComparison.Ordinal)).Implementation?.GetDisplayName() ?? x.Function.Name));
@ -1081,25 +1102,18 @@ public abstract class BaseProvider : IProvider, ISecretId
foreach (var toolCall in toolCalls)
{
toolCallCount++;
if (toolCallCount > ToolSelectionRules.MAX_TOOL_CALLS)
if (toolCallCount >= ToolSelectionRules.MAX_TOOL_CALLS)
{
var limitMessage = ToolSelectionRules.GetMaxToolCallsLimitMessage();
currentAssistantContent?.ToolInvocations.Add(new ToolInvocationTrace
var finalResponseInstruction = ToolSelectionRules.GetMaxToolCallsFinalResponseInstruction();
internalMessages.Add(new ToolResultMessage
{
Order = toolCallCount,
ToolId = toolCall.Function.Name,
ToolName = toolCall.Function.Name,
Content = finalResponseInstruction,
ToolCallId = toolCall.Id,
Status = ToolInvocationTraceStatus.BLOCKED,
StatusMessage = limitMessage,
Result = limitMessage,
});
await ResetToolRuntimeStatusAsync();
yield return new ContentStreamChunk(limitMessage, []);
yield break;
continue;
}
toolCallCount++;
var (toolContent, trace) = await toolExecutor.ExecuteAsync(
toolCall.Id,
toolCall.Function.Name,

View File

@ -326,13 +326,24 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
while (true)
{
var finalResponseRequired = toolCallCount >= ToolSelectionRules.MAX_TOOL_CALLS;
var requestInput = new List<object>(baseInput);
if (finalResponseRequired && requestInput.FirstOrDefault() is TextMessage systemPrompt)
{
requestInput[0] = systemPrompt with
{
Content = $"{systemPrompt.Content}{Environment.NewLine}{Environment.NewLine}{ToolSelectionRules.GetMaxToolCallsFinalResponseInstruction()}",
};
}
requestInput.AddRange(internalItems);
var requestDto = new ResponsesAPIRequest
{
Model = chatModel.Id,
Input = [..baseInput, ..internalItems],
Input = requestInput,
Stream = false,
Store = false,
Tools = providerTools,
Tools = finalResponseRequired ? [] : providerTools,
AdditionalApiParameters = apiParameters,
};
var response = await this.ExecuteResponsesRequest(requestDto, requestedSecret, token);
@ -342,6 +353,19 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
yield break;
}
if (finalResponseRequired)
{
await ResetToolRuntimeStatusAsync(currentAssistantContent);
var textOutput = response.GetTextOutput();
if (!string.IsNullOrWhiteSpace(textOutput))
yield return new ContentStreamChunk(textOutput, []);
else
yield return new ContentStreamChunk("The model did not return a final answer after completing the available tool calls.", []);
yield break;
}
var functionCalls = response.GetFunctionCalls();
if (functionCalls.Count == 0)
{
@ -364,26 +388,18 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
foreach (var functionCall in functionCalls)
{
toolCallCount++;
if (toolCallCount > ToolSelectionRules.MAX_TOOL_CALLS)
if (toolCallCount >= ToolSelectionRules.MAX_TOOL_CALLS)
{
var limitMessage = ToolSelectionRules.GetMaxToolCallsLimitMessage();
currentAssistantContent?.ToolInvocations.Add(new ToolInvocationTrace
var finalResponseInstruction = ToolSelectionRules.GetMaxToolCallsFinalResponseInstruction();
internalItems.Add(new ResponsesFunctionCallOutputItem
{
Order = toolCallCount,
ToolId = functionCall.Name,
ToolName = functionCall.Name,
ToolCallId = functionCall.CallId,
Status = ToolInvocationTraceStatus.BLOCKED,
StatusMessage = limitMessage,
Result = limitMessage,
CallId = functionCall.CallId,
Output = finalResponseInstruction,
});
await ResetToolRuntimeStatusAsync(currentAssistantContent);
yield return new ContentStreamChunk(limitMessage, []);
yield break;
continue;
}
toolCallCount++;
var (toolContent, trace) = await toolExecutor.ExecuteAsync(
functionCall.CallId,
functionCall.Name,

View File

@ -43,9 +43,9 @@ public sealed class ReadWebPageTool(HTMLParser htmlParser, ILogger<ReadWebPageTo
public string GetSettingsFieldDescription(string fieldName, ToolSettingsFieldDefinition fieldDefinition) => fieldName switch
{
"timeoutSeconds" => TB("Optional HTTP timeout for loading a web page in seconds."),
"maxContentCharacters" => TB("Optional global truncation limit for extracted characters returned to the model."),
ALLOWED_PRIVATE_HOSTS_SETTING => TB("Optional host allowlist for private or VPN web pages. For security reasons, private or VPN web pages aren't allowed to be read by default. Separate host patterns with commas, such as example.de, *.example.de. Allowed private hosts require a high-confidence provider. For allowed internal hosts, AI Studio also tries the operating system's default sign-in automatically when the server responds with integrated authentication."),
"timeoutSeconds" => TB("(Optional) HTTP timeout for loading a web page in seconds."),
"maxContentCharacters" => TB("(Optional) Global truncation limit for extracted characters returned to the model."),
ALLOWED_PRIVATE_HOSTS_SETTING => TB("(Optional) Host allowlist for private or VPN web pages. For security reasons, private or VPN web pages aren't allowed to be read by default. Separate host patterns with commas, such as example.de, *.example.de. Allowed private hosts require a high-confidence provider. For allowed internal hosts, AI Studio also tries the operating system's default sign-in automatically when the server responds with integrated authentication."),
_ => TB(fieldDefinition.Description),
};

View File

@ -33,7 +33,7 @@ public static class ToolSelectionRules
_ => ConfidenceLevel.NONE,
};
public static string GetMaxToolCallsLimitMessage() => $"Tool calling stopped because the maximum of {MAX_TOOL_CALLS} tool calls was reached.";
public static string GetMaxToolCallsFinalResponseInstruction() => $"The maximum of {MAX_TOOL_CALLS} tool calls has been reached. No more tools are available. Provide the best possible final answer to the user based on the tool results already available.";
public static string BuildToolPolicyPrompt(IEnumerable<ToolDefinition> definitions)
{