fix(openai): ask_user_choice-Intercept, Interleaved Thinking in History, MiniMax-Debug-Log

OpenAI-kompatible Provider (MiniMax, DeepSeek-R1, QwQ, Kimi K2, etc.) verloren bei
jeder neuen Iteration ihre eigene Chain-of-Thought, weil thinking-Messages aus der
DB-History rausgefiltert wurden. Folge: Modell kündigt Action an, ruft Tool aber
nicht auf → Stream endet mit finish_reason:stop ohne Tool-Call. Zusätzlich fehlte
der ask_user_choice-Intercept (Tool wurde als unbekannt abgelehnt) und es gab keine
Diagnose-Möglichkeit für unerwartete Stop-Verhalten.

- ChatService.BuildHistoryFromDb(includeThinking): neuer Parameter, fügt
  thinking-Messages als {type:"thinking"}-Block in die Assistant-Message ein.
  Text/Tool-Use derselben Iteration werden via AppendToLastMessage angehängt
  → ein Assistant-Block enthält [thinking, text, tool_use…].
- OpenAiService.ConvertMessage + StreamChat + ChatNonStreaming: Thinking als
  <think>...</think>-Prefix dem Assistant-Content voranstellen (Per-Iteration und
  beim DB-History-Aufbau). Konvention von MiniMax-Docs, wird auch von DeepSeek-R1,
  QwQ, Kimi K2 verstanden.
- ChatEndpoint, SessionEndpoint, CronjobService, TelegramService, ToolService:
  includeThinking je nach Provider durchgereicht. Anthropic-Pfad bleibt aus
  (lehnt thinking-Blöcke ohne Signature ab).
- OpenAiService: ask_user_choice-Intercept im Streaming-Pfad (analog
  AnthropicService.Streaming) – Tool nicht ausführen, SSE user_choice rausschicken,
  mit stopReason awaiting_user_choice finalisieren und return.
- OpenAiService: MiniMax-SSE-Debug-Log in data/openai-debug/ (JSONL: Header +
  Roh-data-Zeilen + Summary mit promptTokens/completionTokens/finishReason/
  toolCalls/duration) – nur für provider=="minimax", nicht-blockierend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.7 committed 2026-05-14 10:19:06 +02:00
1 parent d66f8d2e73
commit ecddbe5f36
7 files changed
+226 -20

No files matched your search

+7 -2
View File
@@ -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
// <think>...</think>-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;
+3 -2
View File
@@ -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);
+26 -6
View File
@@ -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).
/// </summary>
public async Task<List<object>> BuildHistoryFromDb(string sessionId, string includeTag = null, List<PromptBlock> promptBlocks = null)
public async Task<List<object>> BuildHistoryFromDb(string sessionId, string includeTag = null, List<PromptBlock> 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<string, object> { ["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 <think>...</think>
// dem Assistant-Text vorangestellt (laut MiniMax-Docs: "the content field
// will contain <think> 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<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "thinking", thinking = msg.Content } }
});
}
}
else if (msg.Role == "assistant" && msg.Type == "text")
{
if (!string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { 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")
+3 -2
View File
@@ -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 →
// <think>...</think>-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<object> { new { type = "text", text = job.Prompt } };
+181 -6
View File
@@ -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<object>();
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 <think>...</think>-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 = $"<think>\n{finalThinking}\n</think>\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<object>();
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<JsonElement>(tcArgs.ToString()); }
catch { choiceInput = JsonSerializer.Deserialize<JsonElement>("{}"); }
var question = choiceInput.TryGetProperty("question", out var qProp) ? qProp.GetString() ?? "" : "";
var choices = new List<string>();
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 <think>...</think>-Prefix dem content voranstellen, damit das Modell
// in nachfolgenden Iterationen seine eigene Chain-of-Thought sieht.
var assistantMsg = JsonSerializer.Deserialize<JsonElement>(message.GetRawText());
var iterContent = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "";
if (!string.IsNullOrEmpty(iterationThinking))
iterContent = $"<think>\n{iterationThinking}\n</think>\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<List<object>>(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<object>();
var toolResults = new List<object>();
@@ -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 <think>...</think>-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 <think>...</think>-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 = $"<think>\n{thinkingStr}\n</think>\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<object> wird im Aufrufer per OfType<List<object>> erkannt
var combined = textParts.ToString().Trim();
var combined = PrependThinking(textParts.ToString().Trim());
if (string.IsNullOrEmpty(combined)) return null;
return new { role, content = combined };
}
+3 -1
View File
@@ -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 → <think>...</think>-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<string, object>
{
["role"] = "user",
+3 -1
View File
@@ -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 → <think>...</think>-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}";