From bf95dc4eace0550dbef5f4fc998308c83e76d8f3 Mon Sep 17 00:00:00 2001 From: Jicheng Lu <103353@smsassist.com> Date: Thu, 3 Sep 2026 14:52:26 -0500 Subject: [PATCH] Handle parallel tool calls in OpenAI Response API paths The Response API paths kept only the first function call the model asked for, so parallel tool calls were dropped and the model re-asked for the rest on the next turn. The streaming path was worse: each output-item-done update overwrote the previous call, keeping only the last. Collect every FunctionCallResponseItem and populate ToolCalls on the returned RoleDialogModel, matching the Chat API paths. ToolCallId, FunctionName and FunctionArgs still carry the first call, so existing single-call consumers are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .../Chat/ChatCompletionProvider.Response.cs | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Response.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Response.cs index 678010118..13190103f 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Response.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Response.cs @@ -25,7 +25,11 @@ private async Task InnerCreateResponse(Agent agent, List().FirstOrDefault(); + // Every call the model asked for, not only the first. It routinely asks for several + // independent ones at once, and keeping one made it re-ask for the rest next turn. + var functionCalls = value.OutputItems.OfType().ToList(); + var toolCalls = ToLlmToolCalls(functionCalls); + var functionCall = functionCalls.FirstOrDefault(); var reasoningItem = value.OutputItems.OfType().FirstOrDefault(); var text = value.GetOutputText() ?? string.Empty; var thinkingText = reasoningItem?.GetSummaryText(); @@ -33,7 +37,7 @@ private async Task InnerCreateResponse(Agent agent, List x.FunctionName))}"); responseMessage = new RoleDialogModel(AgentRole.Function, text) { @@ -42,6 +46,7 @@ private async Task InnerCreateResponse(Agent agent, List InnerCreateResponseAsync(Agent agent, var response = await responsesClient.CreateResponseAsync(options); var value = response.Value; - var functionCall = value.OutputItems.OfType().FirstOrDefault(); + // Every call the model asked for, not only the first. It routinely asks for several + // independent ones at once, and keeping one made it re-ask for the rest next turn. + var functionCalls = value.OutputItems.OfType().ToList(); + var toolCalls = ToLlmToolCalls(functionCalls); + var functionCall = functionCalls.FirstOrDefault(); var reasoningItem = value.OutputItems.OfType().FirstOrDefault(); var text = value.GetOutputText() ?? string.Empty; var thinkingText = reasoningItem?.GetSummaryText(); @@ -121,7 +130,7 @@ private async Task InnerCreateResponseAsync(Agent agent, RoleDialogModel responseMessage; if (functionCall != null) { - _logger.LogInformation($"Action: {nameof(InnerCreateResponseAsync)}, Agent: {agent.Name}, ToolCall: {functionCall.FunctionName}"); + _logger.LogInformation($"Action: {nameof(InnerCreateResponseAsync)}, Agent: {agent.Name}, ToolCalls: {string.Join(",", toolCalls.Select(x => x.FunctionName))}"); responseMessage = new RoleDialogModel(AgentRole.Function, text) { @@ -130,6 +139,7 @@ private async Task InnerCreateResponseAsync(Agent agent, ToolCallId = functionCall.CallId, FunctionName = functionCall.FunctionName.NormalizeFunctionName(), FunctionArgs = functionCall.FunctionArguments?.ToString(), + ToolCalls = toolCalls, RenderedInstruction = string.Join("\r\n", renderedInstructions) }; } @@ -221,7 +231,7 @@ private async Task InnerCreateResponseStreamingAsync(Agent agen using var textStream = new RealtimeTextStream(); using var thinkingStream = new RealtimeTextStream(); - FunctionCallResponseItem? functionCall = null; + var functionCalls = new List(); ResponseResult? finalResult = null; ResponseTokenUsage? tokenUsage = null; @@ -312,7 +322,7 @@ private async Task InnerCreateResponseStreamingAsync(Agent agen { if (itemDone.Item is FunctionCallResponseItem fc) { - functionCall = fc; + functionCalls.Add(fc); #if DEBUG _logger.LogDebug($"Tool Call (id: {fc.CallId}) => {fc.FunctionName}({fc.FunctionArguments})"); #endif @@ -342,9 +352,12 @@ private async Task InnerCreateResponseStreamingAsync(Agent agen var allText = textStream.GetText(); var thinkingText = thinkingStream.GetText(); + var toolCalls = ToLlmToolCalls(functionCalls); + var functionCall = functionCalls.FirstOrDefault(); + if (functionCall != null) { - _logger.LogInformation($"Action: {nameof(InnerCreateResponseStreamingAsync)}, Agent: {agent.Name}, ToolCall: {functionCall.FunctionName}"); + _logger.LogInformation($"Action: {nameof(InnerCreateResponseStreamingAsync)}, Agent: {agent.Name}, ToolCalls: {string.Join(",", toolCalls.Select(x => x.FunctionName))}"); responseMessage = new RoleDialogModel(AgentRole.Function, allText) { @@ -353,6 +366,7 @@ private async Task InnerCreateResponseStreamingAsync(Agent agen ToolCallId = functionCall.CallId, FunctionName = functionCall.FunctionName.NormalizeFunctionName(), FunctionArgs = functionCall.FunctionArguments?.ToString(), + ToolCalls = toolCalls, RenderedInstruction = string.Join("\r\n", renderedInstructions) }; } @@ -822,4 +836,9 @@ private void SetResponseFormat(CreateResponseOptions options, AgentLlmConfig? ll } : null; } #endregion + + private static List ToLlmToolCalls(IEnumerable? functionCalls) + => (functionCalls ?? []) + .Select(x => new LlmToolCall(x.CallId, x.FunctionName, x.FunctionArguments?.ToString())) + .ToList(); }