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