diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index c7356f5..9aea217 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -240,7 +240,7 @@ public static class ChatEndpoint return; } - await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, apiKeyOverride: openAiKeyOverride, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, sseCt: sseCt); + await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, maxIterations: ctx.MaxIterations, apiKeyOverride: openAiKeyOverride, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, sseCt: sseCt); } else { diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index 1d37577..7e1ad16 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -347,7 +347,11 @@ public static class SessionEndpoint } if (provider == "minimax") - payload["extra_body"] = new { reasoning_split = true }; + { + payload["temperature"] = 1.0; + payload["top_p"] = 0.95; + payload["parallel_tool_calls"] = false; + } if (provider == "nvidia") { diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index 1aadba1..1e46379 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -370,6 +370,9 @@ public class OpenAiService var client = CreateClient(providerConfig.ApiKey); // MiniMax-spezifisch: höheres Output-Budget (Thinking-Modelle brauchen viel) var maxTokens = provider == "minimax" ? 32768 : 16384; + // Bei Auto-Continue: tool_choice = "required" erzwingt einen Tool-Call, + // statt dass das Modell erneut nur eine Ankündigung als Text schreibt. + var toolChoice = autoContinueCount > 0 ? "required" : "auto"; var payload = new Dictionary { ["model"] = modelId, @@ -378,20 +381,23 @@ public class OpenAiService ["max_tokens"] = maxTokens, ["stream_options"] = new { include_usage = true }, ["tools"] = _openAiToolDefs, - ["tool_choice"] = "auto" + ["tool_choice"] = toolChoice }; if (provider == "nvidia" && IsNvidiaThinkingModel(modelId)) payload["chat_template_kwargs"] = new { thinking = true }; - // MiniMax-spezifisch: reasoning_split → Thinking landet in delta.reasoning_details - // statt als ... inline in delta.content (siehe MiniMax-OpenAI-Compat-Docs). - // Sampling-Parameter: temperature=1.0, top_p=0.95 (MiniMax-Empfehlung für Agentic Performance). + // MiniMax-spezifisch: + // - temperature=1.0, top_p=0.95: MiniMax-Empfehlung für agentic Performance + // - parallel_tool_calls=false: Modell muss nur einen Tool-Call planen statt mehrere parallel, + // was bei komplexen Multi-Step-Tasks die Zuverlässigkeit erhöht + // Hinweis: reasoning_split=true wurde entfernt – wird von der gehosteten MiniMax-API ignoriert + // (Thinking kommt trotzdem als inline ... in delta.content) if (provider == "minimax") { - payload["extra_body"] = new { reasoning_split = true }; payload["temperature"] = 1.0; payload["top_p"] = 0.95; + payload["parallel_tool_calls"] = false; } var jsonPayload = JsonSerializer.Serialize(payload); @@ -857,8 +863,9 @@ public class OpenAiService if (!string.IsNullOrEmpty(continueContent)) apiMessages.Add(new { role = "assistant", content = continueContent }); - // Synthetische User-Message injizieren - apiMessages.Add(new { role = "user", content = "Continue." }); + // Synthetische User-Message injizieren – direktiv, nicht nur "Continue." + // Damit das Modell den Tool-Call tatsächlich ausführt statt nur erneut anzukündigen. + apiMessages.Add(new { role = "user", content = "You announced an action but didn't execute it. Call the appropriate tool now to carry out what you described. Do not repeat the announcement." }); // SSE-Event an Frontend senden await SseHelper.TryWriteSse(response, "auto_continue", new @@ -885,7 +892,10 @@ public class OpenAiService return; } - // Tool-Calls ausführen + // Tool-Calls ausführen → autoContinueCount zurücksetzen, damit tool_choice + // wieder "auto" wird (Modell darf wieder frei zwischen Text und Tool-Call wählen) + autoContinueCount = 0; + // Assistant-Message mit tool_calls in die History einfügen var toolCallsForHistory = new List(); foreach (var (_, (tcId, tcName, tcArgs, tcTs)) in toolCallAccumulators) @@ -1150,7 +1160,11 @@ public class OpenAiService payload["chat_template_kwargs"] = new { thinking = true }; if (provider == "minimax") - payload["extra_body"] = new { reasoning_split = true }; + { + payload["temperature"] = 1.0; + payload["top_p"] = 0.95; + payload["parallel_tool_calls"] = false; + } var jsonPayload = JsonSerializer.Serialize(payload); var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); @@ -1513,9 +1527,74 @@ public class OpenAiService } apiMessages.Add(converted); } + + // Thinking aus älteren Turns strippen (Kontext-Spar-Maßnahme): + // MiniMax/DeepSeek-R1/QwQ brauchen ihr eigenes Thinking in der History, + // aber nur die letzten paar Turns sind relevant – ältere Thinking-Blöcke + // blähen den Kontext auf und verursachen Tool-Call-Degradation ab ~20k Tokens. + StripOldThinking(apiMessages, keepRecentCount: 4); + return apiMessages; } + /// + /// Entfernt ...-Blöcke aus älteren Assistant-Messages, + /// behält sie aber in den letzten Turns. + /// Reduziert Kontext-Inflation bei Thinking-Modellen (MiniMax-M2, DeepSeek-R1, etc.). + /// + private static void StripOldThinking(List apiMessages, int keepRecentCount) + { + // Finde alle Indizes von Assistant-Messages mit -Blöcken + var assistantIndices = new List(); + for (int i = 0; i < apiMessages.Count; i++) + { + var json = JsonSerializer.Serialize(apiMessages[i]); + if (json.Contains("\"role\":\"assistant\"") && json.Contains("")) + assistantIndices.Add(i); + } + + // Nur die ältesten strippen, die neuesten keepRecentCount behalten + var stripCount = assistantIndices.Count - keepRecentCount; + if (stripCount <= 0) return; + + for (int j = 0; j < stripCount; j++) + { + var idx = assistantIndices[j]; + var json = JsonSerializer.Serialize(apiMessages[idx]); + var el = JsonSerializer.Deserialize(json); + + if (!el.TryGetProperty("content", out var contentProp) || contentProp.ValueKind != JsonValueKind.String) + continue; + + var content = contentProp.GetString() ?? ""; + // ...\n\n\n entfernen (greedy innerhalb eines Blocks) + var stripped = System.Text.RegularExpressions.Regex.Replace( + content, @"[\s\S]*?\s*", "", System.Text.RegularExpressions.RegexOptions.None); + stripped = stripped.TrimStart(); + + if (string.IsNullOrWhiteSpace(stripped)) + { + // Kein sichtbarer Text übrig nach Strip → Message trotzdem behalten falls tool_calls dran sind + if (json.Contains("\"tool_calls\"")) + stripped = ""; + else + continue; // Komplett leer → überspringen + } + + // Neue Message mit gestripptem Content erstellen + var dict = JsonSerializer.Deserialize>(json); + var newDict = new Dictionary(); + foreach (var kv in dict) + { + if (kv.Key == "content") + newDict["content"] = stripped; + else + newDict[kv.Key] = kv.Value; + } + apiMessages[idx] = newDict; + } + } + private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw) => SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw);