diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs
index 56153f1..fd2618b 100644
--- a/Endpoints/ChatEndpoint.cs
+++ b/Endpoints/ChatEndpoint.cs
@@ -185,8 +185,13 @@ public static class ChatEndpoint
} // !skipPreparer
}
- // History für den Session-Agent: OHNE Sub-Agent-Messages (saubere History)
- var messages = await chatService.BuildHistoryFromDb(sessionId, promptBlocks: ctx.PromptBlocks);
+ // History für den Session-Agent: OHNE Sub-Agent-Messages (saubere History).
+ // includeThinking=true für OpenAI-kompatible Provider → Thinking wird als
+ // ... -Prefix dem Assistant-Text vorangestellt (laut MiniMax-Docs essentiell
+ // für Tool-Calling-Reliability / Interleaved Thinking). Anthropic-Pfad braucht das nicht,
+ // weil dort thinking-Blöcke nur mit Signatur akzeptiert werden (die wir nicht persistieren).
+ var includeThinkingForProvider = !string.IsNullOrEmpty(ctx.Provider);
+ var messages = await chatService.BuildHistoryFromDb(sessionId, promptBlocks: ctx.PromptBlocks, includeThinking: includeThinkingForProvider);
// Bei Choice-Response: toolChoice auf "auto" setzen, damit Claude frei antworten kann
var effectiveToolChoice = isChoiceResponse ? "auto" : ctx.ToolChoice;
diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs
index 4087ccf..f681547 100644
--- a/Endpoints/SessionEndpoint.cs
+++ b/Endpoints/SessionEndpoint.cs
@@ -287,8 +287,9 @@ public static class SessionEndpoint
// Model
var modelId = ChatService.ResolveModelId(agent.Model);
- // Messages aus der DB (exakt wie beim Chat)
- var messages = await chatService.BuildHistoryFromDb(session.SessionId, promptBlocks: promptBlocks);
+ // Messages aus der DB (exakt wie beim Chat) – includeThinking je nach Provider
+ var previewProvider = OpenAiService.IsOpenAiProvider(modelId);
+ var messages = await chatService.BuildHistoryFromDb(session.SessionId, promptBlocks: promptBlocks, includeThinking: previewProvider);
// Tools (exakt wie AddToolsToPayload es machen würde)
var hasWorkspace = !string.IsNullOrEmpty(workspace);
diff --git a/Services/ChatService.cs b/Services/ChatService.cs
index bb132f0..5ef007e 100644
--- a/Services/ChatService.cs
+++ b/Services/ChatService.cs
@@ -232,7 +232,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
/// includeTag: Wenn gesetzt, werden Messages mit diesem Tag NICHT ausgefiltert
/// (z.B. "preparer" damit der Preparer seine eigenen vorherigen Messages sieht).
///
- public async Task> BuildHistoryFromDb(string sessionId, string includeTag = null, List promptBlocks = null)
+ public async Task> BuildHistoryFromDb(string sessionId, string includeTag = null, List promptBlocks = null, bool includeThinking = false)
{
var chainsList = (await _db.GetActiveChains(sessionId)).ToList();
// chronologisch sortieren (älteste zuerst)
@@ -359,15 +359,35 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
if (content.Count > 0)
history.Add(new Dictionary { ["role"] = "user", ["content"] = content });
}
+ else if (msg.Role == "assistant" && msg.Type == "thinking" && includeThinking)
+ {
+ // Thinking-Inhalt für OpenAI-kompatible Provider mitführen.
+ // Wird in OpenAiService.ConvertMessage als ...
+ // dem Assistant-Text vorangestellt (laut MiniMax-Docs: "the content field
+ // will contain tag content, which must be preserved completely"
+ // → essential für Interleaved Thinking, Tool-Calling-Reliability,
+ // siehe https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2).
+ // Anthropic-Pfad: includeThinking=false → Block wird übersprungen,
+ // weil Anthropic-API thinking-Blöcke ohne Signature ablehnt.
+ if (!string.IsNullOrEmpty(msg.Content))
+ {
+ // NEUE assistant message starten; text/tool_use derselben Iteration
+ // werden via AppendToLastMessage angehängt → ein assistant-Block enthält
+ // dann [thinking, text, tool_use…].
+ history.Add(new Dictionary
+ {
+ ["role"] = "assistant",
+ ["content"] = new List { new { type = "thinking", thinking = msg.Content } }
+ });
+ }
+ }
else if (msg.Role == "assistant" && msg.Type == "text")
{
if (!string.IsNullOrEmpty(msg.Content))
{
- history.Add(new Dictionary
- {
- ["role"] = "assistant",
- ["content"] = new List { new { type = "text", text = msg.Content } }
- });
+ // Wenn die letzte Message bereits assistant ist (z.B. mit Thinking-Block
+ // aus derselben Iteration), Text dort anhängen statt neue Message zu starten.
+ AppendToLastMessage(history, "assistant", new { type = "text", text = msg.Content });
}
}
else if (msg.Role == "assistant" && msg.Type == "tool_use")
diff --git a/Services/CronjobService.cs b/Services/CronjobService.cs
index 8a3f5fc..1e51321 100644
--- a/Services/CronjobService.cs
+++ b/Services/CronjobService.cs
@@ -95,8 +95,9 @@ public class CronjobService : BackgroundService
throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen (User {job.UserId})");
ctx.ApiToken = userApiKey.ApiKey;
- // History aus DB laden
- var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks);
+ // History aus DB laden. includeThinking=true für OpenAI-kompatible Provider →
+ // ... -Prefix wird dem Assistant-Text vorangestellt.
+ var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(ctx.Provider));
// User-Nachricht hinzufügen
var userContent = new List { new { type = "text", text = job.Prompt } };
diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs
index ee4623e..5252ddb 100644
--- a/Services/OpenAiService.cs
+++ b/Services/OpenAiService.cs
@@ -387,6 +387,41 @@ public class OpenAiService
using var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
using var reader = new System.IO.StreamReader(stream);
+ // Diagnose-Logging für MiniMax: jede SSE-data-Line + Header + Summary in JSONL.
+ // Verzeichnis: data/openai-debug/{provider}-{ts}-{chainId8}.jsonl
+ System.IO.StreamWriter debugWriter = null;
+ string debugLogPath = null;
+ long debugLineCount = 0;
+ string lastFinishReason = null;
+ if (provider == "minimax")
+ {
+ try
+ {
+ var dataDir = System.IO.Path.Combine(AppContext.BaseDirectory, "data", "openai-debug");
+ System.IO.Directory.CreateDirectory(dataDir);
+ var ts = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff");
+ var chainShort = chainId.Length >= 8 ? chainId.Substring(0, 8) : chainId;
+ debugLogPath = System.IO.Path.Combine(dataDir, $"{provider}-{ts}-{chainShort}.jsonl");
+ debugWriter = new System.IO.StreamWriter(
+ new System.IO.FileStream(debugLogPath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.Read),
+ new UTF8Encoding(false))
+ { AutoFlush = true };
+ var header = JsonSerializer.Serialize(new
+ {
+ meta = true,
+ provider,
+ model = modelId,
+ chainId,
+ iteration,
+ promptMessages = apiMessages.Count,
+ maxTokens,
+ started = DateTime.UtcNow.ToString("o")
+ });
+ await debugWriter.WriteLineAsync(header);
+ }
+ catch { debugWriter = null; debugLogPath = null; }
+ }
+
var fullText = new StringBuilder();
var thinkingText = new StringBuilder();
// Tool-Call Akkumulatoren: id → (name, arguments)
@@ -409,6 +444,19 @@ public class OpenAiService
if (!line.StartsWith("data:")) continue;
var data = line[5..].Trim();
+
+ // Diagnose-Log: rohe data-Zeile als JSONL-Record
+ if (debugWriter != null)
+ {
+ try
+ {
+ var rec = JsonSerializer.Serialize(new { t = DateTime.UtcNow.ToString("o"), raw = data });
+ await debugWriter.WriteLineAsync(rec);
+ debugLineCount++;
+ }
+ catch { /* Debug-Log darf den Stream nicht killen */ }
+ }
+
if (data == "[DONE]") break;
JsonElement parsed;
@@ -425,6 +473,7 @@ public class OpenAiService
if (choice.TryGetProperty("finish_reason", out var finishProp) && finishProp.ValueKind != JsonValueKind.Null)
{
var fr = (finishProp.GetString() ?? "").ToLowerInvariant();
+ lastFinishReason = fr;
if (fr == "tool_calls" || fr == "function_call") stopReason = "tool_use";
else if (fr == "stop") stopReason = "end_turn";
else if (fr == "length") { stopReason = "max_tokens"; gotLengthFinish = true; }
@@ -655,6 +704,41 @@ public class OpenAiService
catch (InvalidOperationException) { /* Unerwartetes JSON-Format von Provider – Zeile überspringen */ }
}
+ // Diagnose-Log: Summary am Ende des Streams
+ if (debugWriter != null)
+ {
+ try
+ {
+ var toolCallSummaries = new List();
+ foreach (var (idx, (tcId, tcName, tcArgs, _)) in toolCallAccumulators)
+ {
+ toolCallSummaries.Add(new { idx, id = tcId, name = tcName, argsLen = tcArgs.Length });
+ }
+ var summary = JsonSerializer.Serialize(new
+ {
+ summary = true,
+ chainId,
+ iteration,
+ promptTokens = usage.InputTokens,
+ completionTokens = usage.OutputTokens,
+ finishReason = lastFinishReason,
+ stopReason,
+ toolCalls = toolCallSummaries,
+ toolCallCount = toolCallAccumulators.Count,
+ textLen = fullText.Length,
+ thinkingLen = thinkingText.Length,
+ sseLines = debugLineCount,
+ durationMs = sw.ElapsedMilliseconds,
+ ended = DateTime.UtcNow.ToString("o")
+ });
+ await debugWriter.WriteLineAsync(summary);
+ await debugWriter.FlushAsync();
+ debugWriter.Dispose();
+ }
+ catch { try { debugWriter?.Dispose(); } catch { } }
+ debugWriter = null;
+ }
+
// Inline-Think-Buffer-Rest flushen (falls Stream mit partiellem Tag-Fragment endete)
if (inlineThinkBuf.Length > 0)
{
@@ -769,17 +853,62 @@ public class OpenAiService
});
}
+ // Thinking als ... -Prefix dem finalText voranstellen, damit
+ // das Modell in nachfolgenden Iterationen seine eigene Chain-of-Thought sieht
+ // (essential für MiniMax-M2, DeepSeek-R1, QwQ, Kimi K2 etc. – sonst bricht
+ // Tool-Calling-Reliability ein, siehe MiniMax-Doku zu Interleaved Thinking).
+ var historyContent = string.IsNullOrEmpty(finalText) ? "" : finalText;
+ if (!string.IsNullOrEmpty(finalThinking))
+ historyContent = $"\n{finalThinking}\n \n\n{historyContent}";
+
apiMessages.Add(new
{
role = "assistant",
- content = string.IsNullOrEmpty(finalText) ? "" : finalText,
+ content = historyContent,
tool_calls = toolCallsForHistory
});
// Tool-Results sammeln
var toolResults = new List();
+ bool awaitingChoice = false;
foreach (var (_, (tcId, tcName, tcArgs, _)) in toolCallAccumulators)
{
+ // ask_user_choice: NICHT ausführen, sondern als SSE-Event an Client senden.
+ // Analog zum Anthropic-Pfad (AnthropicService.Streaming.cs:546-576).
+ // tool_result kommt asynchron vom Client via ChatEndpoint (ChoiceToolCallId+ChoiceResult).
+ if (tcName == ToolService.AskUserChoiceToolName)
+ {
+ JsonElement choiceInput;
+ try { choiceInput = JsonSerializer.Deserialize(tcArgs.ToString()); }
+ catch { choiceInput = JsonSerializer.Deserialize("{}"); }
+
+ var question = choiceInput.TryGetProperty("question", out var qProp) ? qProp.GetString() ?? "" : "";
+ var choices = new List();
+ if (choiceInput.TryGetProperty("choices", out var cProp))
+ {
+ if (cProp.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var ch in cProp.EnumerateArray())
+ choices.Add(ch.GetString() ?? "");
+ }
+ else if (cProp.ValueKind == JsonValueKind.String)
+ {
+ // Fallback: Modell hat einen einzelnen String statt Array gesendet
+ choices.Add(cProp.GetString() ?? "");
+ }
+ }
+
+ await SseHelper.TryWriteSse(response, "user_choice", new
+ {
+ toolCallId = tcId,
+ question,
+ choices
+ }, sseCt);
+
+ awaitingChoice = true;
+ continue; // Kein Execute, kein tool_result Save
+ }
+
// Tool-Start SSE
await SseHelper.TryWriteSse(response, "tool_start", new { name = tcName, id = tcId }, sseCt);
@@ -818,6 +947,24 @@ public class OpenAiService
});
}
+ // Wenn ask_user_choice aufgerufen wurde → finalize mit awaiting_user_choice und return.
+ // Die Choice-Antwort kommt async vom Client und triggert eine neue Chain.
+ if (awaitingChoice)
+ {
+ await FinalizeChain(chain, usage, iteration, "awaiting_user_choice", sw);
+ await SseHelper.TryWriteSse(response, "done", new
+ {
+ model = modelString,
+ inputTokens = usage.InputTokens,
+ outputTokens = usage.OutputTokens,
+ iterations = iteration,
+ chainId,
+ durationMs = sw.ElapsedMilliseconds,
+ stopReason = "awaiting_user_choice"
+ }, sseCt);
+ return;
+ }
+
// Tool-Results in History einfügen
foreach (var tr in toolResults)
apiMessages.Add(tr);
@@ -945,11 +1092,13 @@ public class OpenAiService
var finishReason = choice.TryGetProperty("finish_reason", out var frProp) ? frProp.GetString() ?? "" : "";
// Reasoning/Thinking extrahieren (Nvidia Thinking-Modelle)
+ string iterationThinking = null;
if (message.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String)
{
var reasoning = reasoningProp.GetString() ?? "";
if (!string.IsNullOrEmpty(reasoning))
{
+ iterationThinking = reasoning;
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = iterations,
@@ -982,12 +1131,18 @@ public class OpenAiService
break;
}
- // Assistant-Message mit tool_calls in History
+ // Assistant-Message mit tool_calls in History.
+ // Thinking als ... -Prefix dem content voranstellen, damit das Modell
+ // in nachfolgenden Iterationen seine eigene Chain-of-Thought sieht.
var assistantMsg = JsonSerializer.Deserialize(message.GetRawText());
+ var iterContent = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "";
+ if (!string.IsNullOrEmpty(iterationThinking))
+ iterContent = $"\n{iterationThinking}\n \n\n{iterContent}";
+
apiMessages.Add(new
{
role = "assistant",
- content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "",
+ content = iterContent,
tool_calls = JsonSerializer.Deserialize>(toolCallsProp.GetRawText())
});
@@ -1073,6 +1228,7 @@ public class OpenAiService
if (contentProp.ValueKind == JsonValueKind.Array)
{
var textParts = new StringBuilder();
+ var thinkingParts = new StringBuilder();
var toolCalls = new List();
var toolResults = new List();
@@ -1081,7 +1237,15 @@ public class OpenAiService
if (!block.TryGetProperty("type", out var typeProp)) continue;
var blockType = typeProp.GetString() ?? "";
- if (blockType == "text" && block.TryGetProperty("text", out var textProp))
+ if (blockType == "thinking" && block.TryGetProperty("thinking", out var thinkingProp))
+ {
+ // Thinking-Inhalt sammeln; wird unten als ... -Prefix dem
+ // Assistant-Text vorangestellt. So bekommen Thinking-Modelle (MiniMax-M2,
+ // DeepSeek-R1, QwQ, Kimi K2 etc.) ihre eigene Chain-of-Thought zurück, was
+ // laut Provider-Docs entscheidend für Tool-Calling-Reliability ist.
+ thinkingParts.Append(thinkingProp.GetString());
+ }
+ else if (blockType == "text" && block.TryGetProperty("text", out var textProp))
{
textParts.Append(textProp.GetString());
}
@@ -1122,14 +1286,25 @@ public class OpenAiService
}
}
+ // Thinking als ... -Prefix dem Text voranstellen (MiniMax-Konvention,
+ // wird auch von DeepSeek-R1, QwQ, Kimi K2 etc. verstanden).
+ var thinkingStr = thinkingParts.ToString();
+ string PrependThinking(string baseText)
+ {
+ if (string.IsNullOrEmpty(thinkingStr)) return baseText;
+ var prefix = $"\n{thinkingStr}\n \n\n";
+ return string.IsNullOrEmpty(baseText) ? prefix : prefix + baseText;
+ }
+
// Assistant-Message mit tool_calls
if (toolCalls.Count > 0)
{
var text = textParts.ToString().Trim();
+ var combinedTc = PrependThinking(text);
return new
{
role = "assistant",
- content = string.IsNullOrEmpty(text) ? "" : text,
+ content = string.IsNullOrEmpty(combinedTc) ? "" : combinedTc,
tool_calls = toolCalls
};
}
@@ -1138,7 +1313,7 @@ public class OpenAiService
if (toolResults.Count > 0)
return toolResults; // List wird im Aufrufer per OfType> erkannt
- var combined = textParts.ToString().Trim();
+ var combined = PrependThinking(textParts.ToString().Trim());
if (string.IsNullOrEmpty(combined)) return null;
return new { role, content = combined };
}
diff --git a/Services/TelegramService.cs b/Services/TelegramService.cs
index 383b5b5..683d4d2 100644
--- a/Services/TelegramService.cs
+++ b/Services/TelegramService.cs
@@ -184,7 +184,9 @@ public class TelegramService
try
{
- var history = await _chatService.BuildHistoryFromDb(session.SessionId, promptBlocks: ctx.PromptBlocks);
+ // includeThinking=true für OpenAI-kompatible Provider → ... -Prefix
+ // im Assistant-Text (essential für MiniMax/DeepSeek/QwQ Tool-Calling-Reliability).
+ var history = await _chatService.BuildHistoryFromDb(session.SessionId, promptBlocks: ctx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(ctx.Provider));
history.Add(new Dictionary
{
["role"] = "user",
diff --git a/Services/ToolService.cs b/Services/ToolService.cs
index ca24999..244febb 100644
--- a/Services/ToolService.cs
+++ b/Services/ToolService.cs
@@ -814,7 +814,9 @@ public class ToolService
// History aufbauen – User-Message NICHT in DB speichern (wie Cronjob)
// Stattdessen als kontextualisierte Nachricht in die History injizieren
- var history = await ctx.ChatService.BuildHistoryFromDb(targetSessionId, promptBlocks: targetCtx.PromptBlocks);
+ // includeThinking=true für OpenAI-kompatible Provider → ... -Prefix
+ // im Assistant-Text. Anthropic ignoriert das (lehnt Blöcke ohne Signatur ab).
+ var history = await ctx.ChatService.BuildHistoryFromDb(targetSessionId, promptBlocks: targetCtx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(targetCtx.Provider));
var senderAgent = ctx.AgentId != null ? await ctx.Db.GetAgent(ctx.AgentId) : null;
var senderName = senderAgent?.Name ?? ctx.AgentId ?? "Unbekannter Agent";
var senderInfo = $"[Nachricht von Agent \"{senderName}\"]: {message}";