From ce08fc132b9caa338396822ff8e7fb691c91d336 Mon Sep 17 00:00:00 2001 From: Vitali sharp8n Date: Wed, 9 Sep 2026 11:06:12 +0300 Subject: [PATCH] fix(DeepSeekAI): capture and echo reasoning_content for thinking mode tool calls DeepSeek thinking mode (deepseek-v4-flash/pro with reasoning enabled) returns reasoning_content in every delta. For requests carrying tools, the reasoning_content of previous assistant messages MUST be passed back in all subsequent requests, otherwise DeepSeek answers 400 'The reasoning_content ... must be passed back'. Changes: - Bump OpenAI SDK 2.5.0 -> 2.12.0 (JsonPatch public API required to read/write the unknown reasoning_content property; 2.5.0 only stores it internally). - Fix BotSharp.Plugin.OpenAI image quality enum names for SDK 2.12 (GeneratedImageQuality.Low -> LowQuality, Medium -> MediumQuality). - Add RoleDialogModel.ReasoningContent (serialized as reasoning_content) and persist it through DialogElement/DialogMetaData/ConversationStorage. - DeepSeekAI ChatCompletionProvider: * capture reasoning_content from non-streaming responses (ExtractReasoningContent) * capture per-delta reasoning_content in the streaming loop and accumulate it * set ReasoningContent on assistant/function response messages * echo reasoning_content back via AssistantChatMessage JsonPatch in PrepareOptions - RoutingService.InvokeAgent: copy response.ReasoningContent onto the stored Function/Assistant dialog so the echo survives the tool-call round trip. --- Directory.Packages.props | 2 +- .../Conversations/Models/Conversation.cs | 8 ++ .../Conversations/Models/RoleDialogModel.cs | 10 ++ .../Services/ConversationStorage.cs | 5 + .../Routing/RoutingService.InvokeAgent.cs | 1 + .../Providers/Chat/ChatCompletionProvider.cs | 119 ++++++++++++++++-- .../Image/ImageCompletionProvider.cs | 4 +- 7 files changed, 138 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ee123555..015db501 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,7 @@ - + diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs index 675c0885..f95a0579 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs @@ -62,6 +62,10 @@ public class DialogElement [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Payload { get; set; } + [JsonPropertyName("reasoning_content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasoningContent { get; set; } + public DialogElement() { @@ -114,6 +118,10 @@ public class DialogMetaData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ToolCallId { get; set; } + [JsonPropertyName("reasoning_content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasoningContent { get; set; } + [JsonPropertyName("sender_id")] public string? SenderId { get; set; } diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs index 90cc4488..dc3cc24b 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs @@ -71,6 +71,15 @@ public class RoleDialogModel : ITrackableMessage [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? FunctionArgs { get; set; } + /// + /// DeepSeek thinking mode: the chain-of-thought text returned by the model in + /// reasoning_content. Must be echoed back to the API in subsequent requests + /// when the conversation carries tools, otherwise DeepSeek answers 400. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("reasoning_content")] + public string? ReasoningContent { get; set; } + /// /// Set this flag is in OnFunctionExecuting, if true, it won't be executed by InvokeFunction. /// @@ -192,6 +201,7 @@ public class RoleDialogModel : ITrackableMessage FunctionArgs = source.FunctionArgs, FunctionName = source.FunctionName, ToolCallId = source.ToolCallId, + ReasoningContent = source.ReasoningContent, Indication = source.Indication, PostbackFunctionName = source.PostbackFunctionName, RichContent = source.RichContent, diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs index 9e927acb..b1f29925 100644 --- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs @@ -72,6 +72,7 @@ public class ConversationStorage : IConversationStorage FunctionName = meta?.FunctionName, FunctionArgs = meta?.FunctionArgs, ToolCallId = meta?.ToolCallId, + ReasoningContent = dialog.ReasoningContent ?? meta?.ReasoningContent, RichContent = richContent, SecondaryContent = secondaryContent, SecondaryRichContent = secondaryRichContent, @@ -109,6 +110,7 @@ public class ConversationStorage : IConversationStorage FunctionName = dialog.FunctionName, FunctionArgs = dialog.FunctionArgs, ToolCallId = dialog.ToolCallId, + ReasoningContent = dialog.ReasoningContent, CreatedTime = dialog.CreatedAt }; @@ -120,6 +122,7 @@ public class ConversationStorage : IConversationStorage MetaData = meta, Content = dialog.Content, SecondaryContent = dialog.SecondaryContent, + ReasoningContent = dialog.ReasoningContent, Payload = dialog.Payload }; } @@ -135,6 +138,7 @@ public class ConversationStorage : IConversationStorage MessageLabel = dialog.MessageLabel, SenderId = dialog.SenderId, FunctionName = dialog.FunctionName, + ReasoningContent = dialog.ReasoningContent, CreatedTime = dialog.CreatedAt }; @@ -150,6 +154,7 @@ public class ConversationStorage : IConversationStorage SecondaryContent = dialog.SecondaryContent, RichContent = richContent, SecondaryRichContent = secondaryRichContent, + ReasoningContent = dialog.ReasoningContent, Payload = dialog.Payload }; } diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs index e0175a70..d8c85853 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs @@ -56,6 +56,7 @@ public partial class RoutingService message.ToolCallId = response.ToolCallId; message.FunctionName = response.FunctionName; message.FunctionArgs = response.FunctionArgs; + message.ReasoningContent = response.ReasoningContent; message.Indication = response.Indication; message.CurrentAgentId = agent.Id; message.IsStreaming = response.IsStreaming; diff --git a/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs index 6349b1ed..f81143e3 100644 --- a/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs @@ -1,4 +1,5 @@ #pragma warning disable OPENAI001 +#pragma warning disable SCME0001 using BotSharp.Abstraction.Conversations.Enums; using BotSharp.Abstraction.Files; using BotSharp.Abstraction.Files.Models; @@ -10,6 +11,7 @@ using BotSharp.Core.MessageHub; using BotSharp.Plugin.DeepSeek.Providers; using Microsoft.Extensions.Logging; using OpenAI.Chat; +using System.ClientModel.Primitives; namespace BotSharp.Plugin.DeepSeekAI.Providers.Chat; @@ -62,7 +64,8 @@ public class ChatCompletionProvider : IChatCompletion ToolCallId = toolCall?.Id, FunctionName = toolCall?.FunctionName, FunctionArgs = toolCall?.FunctionArguments?.ToString(), - RenderedInstruction = string.Join("\r\n", renderedInstructions) + RenderedInstruction = string.Join("\r\n", renderedInstructions), + ReasoningContent = ExtractReasoningContent(value) }; // Somethings LLM will generate a function name with agent name. @@ -78,6 +81,7 @@ public class ChatCompletionProvider : IChatCompletion CurrentAgentId = agent.Id, MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, RenderedInstruction = string.Join("\r\n", renderedInstructions), + ReasoningContent = ExtractReasoningContent(value), Annotations = value.Annotations?.Select(x => new ChatAnnotation { Title = x.WebResourceTitle, @@ -131,7 +135,8 @@ public class ChatCompletionProvider : IChatCompletion var msg = new RoleDialogModel(AgentRole.Assistant, text) { CurrentAgentId = agent.Id, - RenderedInstruction = string.Join("\r\n", renderedInstructions) + RenderedInstruction = string.Join("\r\n", renderedInstructions), + ReasoningContent = ExtractReasoningContent(value) }; var tokenUsage = response?.Value?.Usage; @@ -163,7 +168,8 @@ public class ChatCompletionProvider : IChatCompletion ToolCallId = toolCall?.Id, FunctionName = toolCall?.FunctionName, FunctionArgs = toolCall?.FunctionArguments?.ToString(), - RenderedInstruction = string.Join("\r\n", renderedInstructions) + RenderedInstruction = string.Join("\r\n", renderedInstructions), + ReasoningContent = ExtractReasoningContent(value) }; // Somethings LLM will generate a function name with agent name. @@ -183,6 +189,7 @@ public class ChatCompletionProvider : IChatCompletion CurrentAgentId = agent.Id, MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, RenderedInstruction = string.Join("\r\n", renderedInstructions), + ReasoningContent = ExtractReasoningContent(value), Annotations = value.Annotations?.Select(x => new ChatAnnotation { Title = x.WebResourceTitle, @@ -228,6 +235,7 @@ public class ChatCompletionProvider : IChatCompletion using var textStream = new RealtimeTextStream(); var toolCalls = new List(); ChatTokenUsage? tokenUsage = null; + var reasoningContentBuilder = new System.Text.StringBuilder(); var responseMessage = new RoleDialogModel(AgentRole.Assistant, string.Empty) { @@ -244,6 +252,14 @@ public class ChatCompletionProvider : IChatCompletion toolCalls.AddRange(choice.ToolCallUpdates); } + // DeepSeek thinking mode streams the chain-of-thought in `reasoning_content` + // (delta level). OpenAI SDK 2.12 keeps it as an unknown property in the JsonPatch. + var reasoningDelta = ReadStreamingReasoningContent(choice); + if (!string.IsNullOrEmpty(reasoningDelta)) + { + reasoningContentBuilder.Append(reasoningDelta); + } + if (!choice.ContentUpdate.IsNullOrEmpty()) { var text = choice.ContentUpdate[0]?.Text ?? string.Empty; @@ -284,7 +300,8 @@ public class ChatCompletionProvider : IChatCompletion MessageId = messageId, ToolCallId = toolCallId, FunctionName = functionName, - FunctionArgs = functionArgument + FunctionArgs = functionArgument, + ReasoningContent = reasoningContentBuilder.Length > 0 ? reasoningContentBuilder.ToString() : null }; } else if (choice.FinishReason.HasValue) @@ -296,7 +313,8 @@ public class ChatCompletionProvider : IChatCompletion { CurrentAgentId = agent.Id, MessageId = messageId, - IsStreaming = true + IsStreaming = true, + ReasoningContent = reasoningContentBuilder.Length > 0 ? reasoningContentBuilder.ToString() : null }; } } @@ -332,6 +350,67 @@ public class ChatCompletionProvider : IChatCompletion _model = model; } + /// + /// Extracts reasoning_content (DeepSeek thinking mode) from a non-streaming + /// . The OpenAI SDK keeps the unknown property in the + /// message's JsonPatch, addressable at $.choices[0].message.reasoning_content. + /// + private static string? ExtractReasoningContent(ChatCompletion value) + { + if (value == null) + { + return null; + } + + try + { + return DecodePatchString(value.Patch.GetJson("$.choices[0].message.reasoning_content"u8)); + } + catch (Exception ex) + { + // Never fail the chat because of a reasoning_content extraction issue. + System.Diagnostics.Debug.WriteLine($"ExtractReasoningContent failed: {ex.Message}"); + } + + return null; + } + + /// + /// Extracts one streaming chunk of reasoning_content from a + /// . Unknown delta properties land in the + /// update's JsonPatch under $.choices[0].delta.reasoning_content. + /// + private static string? ReadStreamingReasoningContent(StreamingChatCompletionUpdate choice) + { + if (choice == null) + { + return null; + } + + try + { + return DecodePatchString(choice.Patch.GetJson("$.choices[0].delta.reasoning_content"u8)); + } + catch (Exception ex) + { + // Never fail the chat because of a reasoning_content extraction issue. + System.Diagnostics.Debug.WriteLine($"ReadStreamingReasoningContent failed: {ex.Message}"); + } + + return null; + } + + private static string? DecodePatchString(BinaryData data) + { + var bytes = data.ToArray(); + if (bytes.Length >= 2 && bytes[0] == (byte)'"' && bytes[^1] == (byte)'"') + { + return System.Text.Encoding.UTF8.GetString(bytes, 1, bytes.Length - 2); + } + + return System.Text.Encoding.UTF8.GetString(bytes); + } + protected (string, IEnumerable, ChatCompletionOptions) PrepareOptions(Agent agent, List conversations) { var agentService = _services.GetRequiredService(); @@ -394,10 +473,13 @@ public class ChatCompletionProvider : IChatCompletion { if (message.Role == AgentRole.Function) { - messages.Add(new AssistantChatMessage(new List + var assistantToolMsg = new AssistantChatMessage(new List { ChatToolCall.CreateFunctionToolCall(message.ToolCallId.IfNullOrEmptyAs(message.FunctionName), message.FunctionName, BinaryData.FromString(message.FunctionArgs ?? "{}")) - })); + }); + EchoReasoningContent(assistantToolMsg, message); + + messages.Add(assistantToolMsg); messages.Add(new ToolChatMessage(message.ToolCallId.IfNullOrEmptyAs(message.FunctionName), message.LlmContent)); } @@ -423,7 +505,10 @@ public class ChatCompletionProvider : IChatCompletion { CollectMessageContentParts(contentParts, message.Files, imageDetailLevel); } - messages.Add(new AssistantChatMessage(contentParts)); + var assistantMsg = new AssistantChatMessage(contentParts); + EchoReasoningContent(assistantMsg, message); + + messages.Add(assistantMsg); } } @@ -431,6 +516,24 @@ public class ChatCompletionProvider : IChatCompletion return (prompt, messages, options); } + /// + /// DeepSeek requires the reasoning_content of previous assistant messages to be + /// echoed back in every follow-up request that carries tools (otherwise 400 + /// "The reasoning_content ... must be passed back"). The OpenAI SDK serializes it via + /// the message's JsonPatch (experimental SCME0001, suppressed by the file-level pragma). + /// + private static void EchoReasoningContent(AssistantChatMessage assistantMessage, RoleDialogModel dialog) + { + var reasoning = dialog.ReasoningContent; + if (string.IsNullOrWhiteSpace(reasoning) || assistantMessage == null) + { + return; + } + + ref var patch = ref assistantMessage.Patch; + patch.Set("$.reasoning_content"u8, reasoning); + } + private void CollectMessageContentParts(List contentParts, List files, ChatImageDetailLevel imageDetailLevel) { foreach (var file in files) diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Image/ImageCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Image/ImageCompletionProvider.cs index 919054b1..acc8391f 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Image/ImageCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Image/ImageCompletionProvider.cs @@ -105,10 +105,10 @@ public partial class ImageCompletionProvider : IImageCompletion switch (value) { case "low": - retQuality = GeneratedImageQuality.Low; + retQuality = GeneratedImageQuality.LowQuality; break; case "medium": - retQuality = GeneratedImageQuality.Medium; + retQuality = GeneratedImageQuality.MediumQuality; break; case "high": retQuality = GeneratedImageQuality.High;