From 9fe969a47c61f6d0dcf00ab5a6446b6e0f3d20df Mon Sep 17 00:00:00 2001 From: Vitali sharp8n Date: Wed, 9 Sep 2026 11:06:12 +0300 Subject: [PATCH] fix(DeepSeekAI): accumulate streaming tool-call args per call index When the model emits several parallel tool calls in one streaming response, the OpenAI SDK interleaves argument fragments by call index. The old code joined ALL fragments of ALL calls into a single FunctionArgs string, which produced corrupted arguments like get_node({"node_id": "10"}{"maxDepth": 2}) and made every tool call fail (e.g. 'node_id is required'). Group tool-call updates by Index and concatenate fragments only within the first call that has a function name; other parallel calls are ignored (same as before), but the selected call now gets clean, valid JSON arguments. Also fix DecodePatchString: DeepSeek sends "reasoning_content": null in the final delta; the decoder returned the literal string 'null' which was appended to the accumulated reasoning text. Return null for the JSON null literal instead. --- .../Providers/Chat/ChatCompletionProvider.cs | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs index f81143e3..4f05ee8c 100644 --- a/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs @@ -284,11 +284,21 @@ public class ChatCompletionProvider : IChatCompletion if (choice.FinishReason == ChatFinishReason.ToolCalls || choice.FinishReason == ChatFinishReason.FunctionCall) { - var meta = toolCalls.FirstOrDefault(x => !string.IsNullOrEmpty(x.FunctionName)); - var functionName = meta?.FunctionName; - var toolCallId = meta?.ToolCallId; - var args = toolCalls.Where(x => x.FunctionArgumentsUpdate != null).Select(x => x.FunctionArgumentsUpdate.ToString()).ToList(); - var functionArgument = string.Join(string.Empty, args); + // The model may emit several tool calls in one response. The OpenAI SDK streams each + // call's arguments as interleaved fragments per call index, so we must accumulate them + // PER CALL (grouped by index), not concatenate every fragment of every call together + // (that would corrupt arguments, e.g. get_node({"node_id": "10"}{"maxDepth": 2})). + var grouped = toolCalls + .Where(x => !string.IsNullOrEmpty(x.FunctionName)) + .GroupBy(x => x.Index) + .OrderBy(g => g.Key) + .ToList(); + + var functionName = grouped.Count > 0 ? grouped[0].First().FunctionName : null; + var toolCallId = grouped.Count > 0 ? grouped[0].First().ToolCallId : null; + var functionArgument = grouped.Count > 0 + ? string.Concat(grouped[0].Where(x => x.FunctionArgumentsUpdate != null).Select(x => x.FunctionArgumentsUpdate.ToString())) + : string.Empty; #if DEBUG _logger.LogCritical($"Tool Call (id: {toolCallId}) => {functionName}({functionArgument})"); @@ -402,7 +412,20 @@ public class ChatCompletionProvider : IChatCompletion private static string? DecodePatchString(BinaryData data) { + if (data == null) + { + return null; + } + var bytes = data.ToArray(); + + // JSON null literal (DeepSeek sends "reasoning_content": null in the final delta). + if (bytes.Length == 4 && bytes[0] == (byte)'n' && bytes[1] == (byte)'u' && bytes[2] == (byte)'l' && bytes[3] == (byte)'l') + { + return null; + } + + // Quoted JSON string. if (bytes.Length >= 2 && bytes[0] == (byte)'"' && bytes[^1] == (byte)'"') { return System.Text.Encoding.UTF8.GetString(bytes, 1, bytes.Length - 2);