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.
This commit is contained in:
Vitali sharp8n 2026-09-09 11:06:12 +03:00
parent ffc9866069
commit ce08fc132b
7 changed files with 138 additions and 11 deletions

View file

@ -45,7 +45,7 @@
<PackageVersion Include="Whisper.net.Runtime" Version="1.8.1" />
<PackageVersion Include="NCrontab" Version="3.3.3" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
<PackageVersion Include="OpenAI" Version="2.5.0" />
<PackageVersion Include="OpenAI" Version="2.12.0" />
<PackageVersion Include="MailKit" Version="4.11.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.8" />
<PackageVersion Include="MySql.Data" Version="9.0.0" />

View file

@ -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; }

View file

@ -71,6 +71,15 @@ public class RoleDialogModel : ITrackableMessage
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FunctionArgs { get; set; }
/// <summary>
/// DeepSeek thinking mode: the chain-of-thought text returned by the model in
/// <c>reasoning_content</c>. Must be echoed back to the API in subsequent requests
/// when the conversation carries tools, otherwise DeepSeek answers 400.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("reasoning_content")]
public string? ReasoningContent { get; set; }
/// <summary>
/// Set this flag is in OnFunctionExecuting, if true, it won't be executed by InvokeFunction.
/// </summary>
@ -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,

View file

@ -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
};
}

View file

@ -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;

View file

@ -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<StreamingChatToolCallUpdate>();
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;
}
/// <summary>
/// Extracts <c>reasoning_content</c> (DeepSeek thinking mode) from a non-streaming
/// <see cref="ChatCompletion"/>. The OpenAI SDK keeps the unknown property in the
/// message's JsonPatch, addressable at <c>$.choices[0].message.reasoning_content</c>.
/// </summary>
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;
}
/// <summary>
/// Extracts one streaming chunk of <c>reasoning_content</c> from a
/// <see cref="StreamingChatCompletionUpdate"/>. Unknown delta properties land in the
/// update's JsonPatch under <c>$.choices[0].delta.reasoning_content</c>.
/// </summary>
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<ChatMessage>, ChatCompletionOptions) PrepareOptions(Agent agent, List<RoleDialogModel> conversations)
{
var agentService = _services.GetRequiredService<IAgentService>();
@ -394,10 +473,13 @@ public class ChatCompletionProvider : IChatCompletion
{
if (message.Role == AgentRole.Function)
{
messages.Add(new AssistantChatMessage(new List<ChatToolCall>
var assistantToolMsg = new AssistantChatMessage(new List<ChatToolCall>
{
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);
}
/// <summary>
/// DeepSeek requires the <c>reasoning_content</c> 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).
/// </summary>
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<ChatMessageContentPart> contentParts, List<BotSharpFile> files, ChatImageDetailLevel imageDetailLevel)
{
foreach (var file in files)

View file

@ -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;