using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Gott.Models; namespace Gott.Services; public class ChatService { private readonly DatabaseService _db; private readonly ToolService _toolService; private readonly MemoryService _memoryService; private readonly SkillService _skillService; private readonly ServerSettingsService _settings; public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService, ServerSettingsService settings) { _db = db; _toolService = toolService; _memoryService = memoryService; _skillService = skillService; _settings = settings; } private int MaxHistory => _settings.GetInt("MaxHistory", 220); private int ReminderInterval => _settings.GetInt("ReminderInterval", 30); public static string ResolveModelId(string model) { // OpenAI-kompatible Provider werden unverändert durchgereicht (Format: "provider:model") if (OpenAiService.IsOpenAiProvider(model)) return model; // Defensiv-Strip: alte "[1m]"-Suffixe aus DB transparent normalisieren. // 1M-Context ist seit März 2026 Default bei Opus 4.6/4.7/4.8 + Sonnet 4.6 – kein Suffix mehr nötig. if (!string.IsNullOrEmpty(model) && model.Contains("[1m]")) model = model.Replace("[1m]", ""); return model switch { "sonnet" => "claude-sonnet-4-6", "sonnet-4-5" => "claude-sonnet-4-5", "sonnet-4-6" => "claude-sonnet-4-6", "opus" => "claude-opus-4-7", "opus-4-5" => "claude-opus-4-5", "opus-4-6" => "claude-opus-4-6", "opus-4-7" => "claude-opus-4-7", "haiku" => "claude-haiku-4-5", "haiku-4-5" => "claude-haiku-4-5", _ => "claude-sonnet-4-6" }; } /// /// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc. /// public async Task PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false, bool hasRemoteConnection = false, string remoteOs = null) { var session = await _db.GetSession(sessionId); if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId)) return null; var agent = await _db.GetAgent(session.AgentId); if (agent == null) return null; var modelId = ResolveModelId(agent.Model); var markedMessages = await _db.GetMarkedMessages(sessionId); var agentMemoryDir = _memoryService.GetAgentMemoryDir(agent.AgentId); var agentSkills = await _db.GetAgentSkills(agent.AgentId); var user = userId != null ? await _db.GetUser(userId) : null; var workspace = WorkspaceHelper.ResolveWorkspace(session, user); if (string.IsNullOrEmpty(workspace) && agentSkills.Any()) workspace = WorkspaceHelper.TempDir; var promptBlocks = SystemPromptBuilder.BuildBlocks( agent, session, markedMessages, _memoryService, agentMemoryDir, agentSkills, _skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode); var systemPromptText = string.Join("\n\n", promptBlocks.Select(b => b.Content)); var hasWorkspace = !string.IsNullOrEmpty(workspace); var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId); var includeGitHubSearch = agent.UseGitHubSearch && _toolService.HasGitHubToken; var includeSerperSearch = agent.UseSerperSearch && _toolService.HasSerperKey; var hasTools = hasWorkspace || includeMemory || includeGitHubSearch || includeSerperSearch; var toolChoice = hasTools ? "any" : null; var toolDefs = hasTools ? _toolService.GetToolDefinitions(new ToolDefinitionOptions { IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory, IncludeUserChoice = agent.AllowUserChoice, IncludeRemote = hasRemoteConnection, RemoteOs = remoteOs, IncludeGitHubSearch = includeGitHubSearch, IncludeSerperSearch = includeSerperSearch }) : Array.Empty(); var toolNames = toolDefs.Length > 0 ? JsonSerializer.Serialize(toolDefs.Select(td => { var json = JsonSerializer.Serialize(td); var el = JsonSerializer.Deserialize(json); return el.GetProperty("name").GetString(); }).ToArray()) : null; var chainId = Guid.NewGuid().ToString("N"); await _db.CreateChain(new ChainRecord { ChainId = chainId, SessionId = sessionId, AgentId = agent.AgentId, Model = modelId, SystemPrompt = systemPromptText, ToolChoice = toolChoice, ToolNames = toolNames, PlanMode = planMode }); await _db.UpdateSessionTimestamp(sessionId); if (!string.IsNullOrEmpty(workspace)) Directory.CreateDirectory(workspace); var systemPrompt = new List(); if (!string.IsNullOrWhiteSpace(systemPromptText)) systemPrompt.Add(new { type = "text", text = systemPromptText }); // Sub-Agents vorbereiten (falls konfiguriert) var preparerContext = await BuildSubAgentContext( session.PreparerAgentId, sessionId, workspace, session, user); var reviewContext = await BuildSubAgentContext( session.ReviewAgentId, sessionId, workspace, session, user); var finalizerContext = await BuildSubAgentContext( session.FinalizerAgentId, sessionId, workspace, session, user); if (preparerContext != null) { preparerContext.AllowUserChoice = preparerContext.Agent?.AllowUserChoice ?? false; preparerContext.SystemPrompt.Add(new { type = "text", text = @"## KRITISCH: Deine Rolle als Vorbereiter-Agent Du bist ein Vorbereiter-Agent. Deine Aufgabe: Die Anfrage des Users aufbereiten, deine Fähigkeiten nutzen, und das Ergebnis an den Session-Agent weiterleiten. ### Standardverhalten: Automatische Weiterleitung Deine Antwort wird AUTOMATISCH an den Session-Agent weitergeleitet. Du musst nichts Besonderes tun – antworte einfach mit dem aufbereiteten Ergebnis. Optional kannst du `[FORWARD]` an den Anfang schreiben, aber es ist nicht nötig. ### Ausnahme: Rückfrage an den User NUR wenn du eine echte Rückfrage hast (Anfrage ist unklar/mehrdeutig), schreibe `[QUESTION]` an den Anfang deiner Antwort. Dann wird deine Antwort dem User als Rückfrage angezeigt und NICHT an den Session-Agent weitergeleitet. ### Nutze deine Fähigkeiten aktiv! Du hast eigene Tools. Nutze sie! Erledige was du kannst (Dateien lesen, Daten aufbereiten, Strukturen erstellen, recherchieren). Der Session-Agent soll ein möglichst gut vorbereitetes Paket bekommen. ### Beispiele: Weiterleitung (wird automatisch an den Session-Agent übergeben): ``` Der User möchte Produktdaten als JSON. Ich habe es bereits erstellt: {""products"": [{""id"": 1, ""name"": ""Widget""}]} Bitte speichere es in products.json. ``` Rückfrage (NUR mit [QUESTION] – stoppt die Weiterleitung): ``` [QUESTION] Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? ```" }); } if (reviewContext != null) reviewContext.MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3; var (provider, _) = OpenAiService.ParseModelString(modelId); return new ChatContext { ChainId = chainId, SessionId = sessionId, ModelId = modelId, Provider = provider, Workspace = workspace, AgentId = agent.AgentId, SystemPromptText = systemPromptText, SystemPrompt = systemPrompt, PromptBlocks = promptBlocks, ToolChoice = toolChoice, UseMemory = agent.UseMemory, WebSearchVersion = agent.WebSearchVersion, MaxIterations = maxIterations, Agent = agent, Session = session, Preparer = preparerContext, Review = reviewContext, Finalizer = finalizerContext, PlanMode = planMode }; } /// /// Findet die Chain-ID die einen bestimmten tool_use-Call enthält. /// Wird genutzt um bei ask_user_choice das tool_result in die richtige Chain zu speichern. /// public async Task FindChainWithToolCall(string sessionId, string toolCallId) { var chains = (await _db.GetActiveChains(sessionId)).ToList(); foreach (var chain in chains) { var messages = await _db.GetChainMessages(chain.ChainId); foreach (var msg in messages) { if (msg.Role == "assistant" && msg.Type == "tool_use" && msg.ToolCallId == toolCallId) return chain.ChainId; } } return null; } /// /// Baut die Message-History aus der DB (aktive Chains). /// Optimiert: Nur Text-Messages (user/assistant) für ältere Chains, /// Tool-Calls nur für die aktuelle Chain, Attachments nur für die letzte User-Message. /// /// /// Baut die Message-History aus der DB (aktive Chains). /// 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, bool includeThinking = false) { var chainsList = (await _db.GetActiveChains(sessionId)).ToList(); // chronologisch sortieren (älteste zuerst) chainsList.Reverse(); var history = new List(); var isLastChain = false; long? lastUserMsgId = null; // Bestimme ab welcher Chain vollständig geladen werden muss (mit Tool-Calls). // Bei ask_user_choice entstehen mehrere Chains die zusammengehören: // Chain A (awaiting_user_choice) → tool_result(iter 999) wird in A gespeichert → Chain B (neue Chain) // Chain B braucht Chain A vollständig, weil A den tool_use UND tool_result enthält. // Drei Fälle, in denen die VORHERIGE Chain vollständig gebraucht wird: // 1. Aktuelle Chain ist leer (gerade erst erstellt) // 2. Aktuelle Chain hat eine tool_result mit Iteration 999 (Choice-Antwort in aktueller Chain) // 3. Vorherige Chain endete mit awaiting_user_choice (Choice-Antwort wurde dort gespeichert) int fullChainStartIndex = chainsList.Count - 1; for (int ci = chainsList.Count - 1; ci >= 1; ci--) { var chainMsgs = (await _db.GetChainMessages(chainsList[ci].ChainId)).ToList(); var prevChainStopReason = chainsList[ci - 1].StopReason; bool needsPrevious = chainMsgs.Count == 0 || chainMsgs.Any(m => m.Type == "tool_result" && m.Iteration == 999) || prevChainStopReason == "awaiting_user_choice"; if (needsPrevious) { fullChainStartIndex = ci - 1; } else { break; } } for (int i = 0; i < chainsList.Count; i++) { var chain = chainsList[i]; isLastChain = (i == chainsList.Count - 1); var msgs = (await _db.GetChainMessages(chain.ChainId)) .Where(m => string.IsNullOrEmpty(m.Tag) || m.Tag == "openclaw-import" || m.Tag == includeTag) // Sub-Agent-Messages ausfiltern; openclaw-import ist Herkunfts-Tag und wird immer durchgelassen .ToList(); // Letzte User-Message-ID der letzten Chain merken (für Attachments) if (isLastChain || i == fullChainStartIndex) { var lastUserMsg = msgs.LastOrDefault(m => m.Role == "user" && m.Type == "text"); if (lastUserMsg != null) lastUserMsgId = lastUserMsg.Id; } // Für ältere Chains: nur Text-Messages (wie Frontend es vorher gemacht hat) if (i < fullChainStartIndex) { foreach (var msg in msgs) { if (msg.Role == "user" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content)) { history.Add(new Dictionary { ["role"] = "user", ["content"] = new List { new { type = "text", text = msg.Content } } }); } else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrWhiteSpace(msg.Content)) { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { new { type = "text", text = msg.Content.Trim() } } }); } } continue; } // Letzte Chain: vollständig mit Tool-Calls var grouped = new Dictionary>(); foreach (var msg in msgs) { if (!grouped.ContainsKey(msg.Iteration)) grouped[msg.Iteration] = new List(); grouped[msg.Iteration].Add(msg); } foreach (var kvp in grouped.OrderBy(x => x.Key)) { foreach (var msg in kvp.Value) { if (msg.Role == "user" && msg.Type == "text") { var content = new List(); // Attachments nur für die letzte User-Message laden if (msg.Id == lastUserMsgId) { var attachments = await _db.GetAttachments(msg.Id); foreach (var att in attachments) { if (att.Type == "image") { content.Add(new { type = "image", source = new { type = "base64", media_type = att.MediaType, data = att.Data } }); } else if (att.Type == "document") { content.Add(new { type = "document", source = new { type = "base64", media_type = att.MediaType, data = att.Data } }); } } } if (!string.IsNullOrEmpty(msg.Content)) content.Add(new { type = "text", text = msg.Content }); 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") { // Trim: Nach -Extraktion bleibt oft nur "\n" als Text übrig. // Whitespace-only Text nicht in die History aufnehmen (verursacht leere Bubbles). var trimmedContent = msg.Content?.Trim(); if (!string.IsNullOrEmpty(trimmedContent)) { // 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 = trimmedContent }); } } else if (msg.Role == "assistant" && msg.Type == "tool_use") { JsonElement inputEl; try { inputEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { inputEl = JsonSerializer.Deserialize("{}"); } // thought_signature mitführen falls vorhanden (für Gemini-Provider) object toolUseBlock = !string.IsNullOrEmpty(msg.ThoughtSignature) ? (object)new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl, thought_signature = msg.ThoughtSignature } : new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }; AppendToLastMessage(history, "assistant", toolUseBlock); } else if (msg.Role == "assistant" && msg.Type == "server_tool_use") { // Server-Tool-Use (web_search, code_execution etc.) – Raw-JSON in Assistant-Content JsonElement useEl; try { useEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { continue; } AppendToLastMessage(history, "assistant", useEl); } else if (msg.Role == "assistant" && msg.Type == "server_tool_result") { // Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.) // werden als Raw-JSON in den Assistant-Content-Block eingefügt JsonElement resultEl; try { resultEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { continue; } AppendToLastMessage(history, "assistant", resultEl); } else if (msg.Role == "assistant" && msg.Type == "web_search_result") { // web_search_tool_result – Raw-JSON in den Assistant-Content-Block einfügen // (API erwartet web_search_tool_result nach server_tool_use vom Typ web_search) JsonElement resultEl; try { resultEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { continue; } // Rückwärtskompatibilität: Alte Einträge enthalten nur Sources-Array statt Raw-JSON // Diese überspringen, da sie kein gültiger web_search_tool_result-Block sind if (resultEl.ValueKind == JsonValueKind.Array) continue; AppendToLastMessage(history, "assistant", resultEl); } else if ((msg.Role == "user" || msg.Role == "tool") && msg.Type == "tool_result") { // Anthropic speichert tool_results mit Role="user", OpenAI mit Role="tool". // Beide werden in Anthropic-Format (user-Message mit tool_result-Block) eingefügt, // da BuildApiMessages später das Format für OpenAI-Provider konvertiert. var toolResultBlock = new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }; AppendToLastMessage(history, "user", toolResultBlock); } } } } // History auf maximal N Messages begrenzen (älteste entfernen) // WICHTIG: Nicht mitten in einem tool_use/tool_result-Paar abschneiden! if (history.Count > MaxHistory) { int removeCount = history.Count - MaxHistory; // Nach vorne korrigieren bis wir an einer sicheren Stelle sind: // Nicht abschneiden wenn die erste verbleibende Message ein tool_result ist // oder die letzte entfernte Message ein assistant mit tool_use ist while (removeCount < history.Count - 1) { var firstRemaining = history[removeCount]; bool isToolResult = false; if (firstRemaining is Dictionary frDict && frDict.TryGetValue("role", out var frRole) && frRole.ToString() == "user" && frDict.TryGetValue("content", out var frContent) && frContent is List frList) { isToolResult = frList.Any(item => { var json = JsonSerializer.Serialize(item); return json.Contains("\"tool_result\""); }); } if (isToolResult) { // Die erste verbleibende Message ist ein tool_result → muss den vorherigen // assistant[tool_use] auch behalten → ein Element weniger entfernen removeCount--; // Jetzt prüfen ob der neue Start auch sicher ist (könnte auch tool_result sein) continue; } // Prüfen ob die letzte entfernte Message ein assistant mit tool_use war bool lastRemovedHasToolUse = false; if (removeCount > 0) { var lastRemoved = history[removeCount - 1]; if (lastRemoved is Dictionary lrDict && lrDict.TryGetValue("role", out var lrRole) && lrRole.ToString() == "assistant" && lrDict.TryGetValue("content", out var lrContent) && lrContent is List lrList) { lastRemovedHasToolUse = lrList.Any(item => { var json = JsonSerializer.Serialize(item); return json.Contains("\"tool_use\""); }); } } if (lastRemovedHasToolUse) { // Letztes entferntes Element enthält tool_use → auch das tool_result entfernen removeCount++; continue; } break; } if (removeCount > 0 && removeCount < history.Count) history.RemoveRange(0, removeCount); } // Reminder einfügen: von hinten gezählt – halbes Intervall vor Ende, dann volle Intervalle var reminder = SystemPromptBuilder.BuildReminder(promptBlocks); int halfInterval = ReminderInterval / 2; if (!string.IsNullOrWhiteSpace(reminder) && ReminderInterval > 0 && history.Count > halfInterval) { var reminderUser = new Dictionary { ["role"] = "user", ["content"] = new List { new { type = "text", text = "[System Reminder] " + reminder } } }; var reminderAssistant = new Dictionary { ["role"] = "assistant", ["content"] = new List { new { type = "text", text = "Verstanden." } } }; // Zielpositionen von hinten berechnen: // Erste Position: history.Count - halfInterval (z.B. 15 vom Ende) // Weitere Positionen: jeweils - ReminderInterval (z.B. 30er-Schritte rückwärts) var targetPositions = new List(); int pos = history.Count - halfInterval; while (pos > 0) { targetPositions.Add(pos); pos -= ReminderInterval; } // Für jede Zielposition die nächste valide Einfügestelle finden var insertPositions = new List(); foreach (int target in targetPositions) { // Suche ab target rückwärts nach einer assistant-Message, // deren Folge-Message kein tool_result ist for (int i = Math.Min(target, history.Count - 1); i >= 1; i--) { if (!(history[i] is Dictionary d && d.TryGetValue("role", out var r) && r.ToString() == "assistant")) continue; bool nextIsToolResult = false; if (i + 1 < history.Count && history[i + 1] is Dictionary nextDict && nextDict.TryGetValue("role", out var nextRole) && nextRole.ToString() == "user" && nextDict.TryGetValue("content", out var nextContent) && nextContent is List nextList) { nextIsToolResult = nextList.Any(item => { var json = JsonSerializer.Serialize(item); return json.Contains("\"tool_result\""); }); } if (!nextIsToolResult) { insertPositions.Add(i + 1); break; } } } // Deduplizieren und von hinten nach vorne einfügen, damit Indizes nicht verschoben werden insertPositions = insertPositions.Distinct().OrderByDescending(x => x).ToList(); foreach (int insertPos in insertPositions) { history.Insert(insertPos, reminderAssistant); history.Insert(insertPos, reminderUser); } } // Post-Processing: Offene tool_use-Blöcke ohne tool_result → synthetische tool_results einfügen FixOrphanedToolUse(history); // Post-Processing: tool_result-Blöcke ohne passendes tool_use entfernen // Tritt auf wenn eine ältere ask_user_choice-Chain als Text-only geladen wird: // Der tool_use(ask_user_choice) geht verloren, aber das tool_result(iter 999) in der nächsten Chain bleibt. FixOrphanedToolResults(history); // Post-Processing: Orphaned web_search server_tool_use entfernen // Bei alten DB-Einträgen wurde web_search_tool_result nur als Sources-Array gespeichert, // nicht als Raw-JSON → BuildHistoryFromDb überspringt sie (Array-Check) → // server_tool_use mit name "web_search" hat kein zugehöriges web_search_tool_result → // API 400 Fehler. Lösung: Solche orphaned Blöcke aus der History entfernen. CleanOrphanedWebSearchBlocks(history); return history; } private static void AppendToLastMessage(List history, string role, object block) { var last = history.Count > 0 ? history[history.Count - 1] : null; if (last is Dictionary dict && dict.TryGetValue("role", out var r) && r.ToString() == role && dict.TryGetValue("content", out var existing) && existing is List contentList) { contentList.Add(block); } else { history.Add(new Dictionary { ["role"] = role, ["content"] = new List { block } }); } } /// /// Entfernt verwaiste web_search-Blöcke aus assistant-Messages: /// - server_tool_use ohne zugehörigen web_search_tool_result (z.B. alte DB-Einträge) /// - web_search_tool_result ohne zugehörigen server_tool_use (z.B. Parse-Fehler) /// private static void CleanOrphanedWebSearchBlocks(List history) { foreach (var msg in history) { if (msg is not Dictionary dict) continue; if (!dict.TryGetValue("role", out var role) || role.ToString() != "assistant") continue; if (!dict.TryGetValue("content", out var content) || content is not List contentList) continue; // Prüfe ob es web_search server_tool_use gibt var webSearchToolUseIds = new HashSet(); var webSearchToolResultIds = new HashSet(); foreach (var block in contentList) { var json = JsonSerializer.Serialize(block); JsonElement el; try { el = JsonSerializer.Deserialize(json); } catch { continue; } var type = el.TryGetProperty("type", out var t) ? t.GetString() : ""; if (type == "server_tool_use") { var name = el.TryGetProperty("name", out var n) ? n.GetString() : ""; var id = el.TryGetProperty("id", out var idProp) ? idProp.GetString() : ""; if (name == "web_search" && !string.IsNullOrEmpty(id)) webSearchToolUseIds.Add(id); } else if (type == "web_search_tool_result") { var toolUseId = el.TryGetProperty("tool_use_id", out var tid) ? tid.GetString() : ""; if (!string.IsNullOrEmpty(toolUseId)) webSearchToolResultIds.Add(toolUseId); } } // Orphaned server_tool_use = IDs ohne zugehörige web_search_tool_result var orphanedUseIds = webSearchToolUseIds.Except(webSearchToolResultIds).ToHashSet(); // Orphaned web_search_tool_result = tool_use_ids ohne zugehörige server_tool_use var orphanedResultIds = webSearchToolResultIds.Except(webSearchToolUseIds).ToHashSet(); if (orphanedUseIds.Count == 0 && orphanedResultIds.Count == 0) continue; // Entferne orphaned server_tool_use und web_search_tool_result Blöcke contentList.RemoveAll(block => { var json = JsonSerializer.Serialize(block); JsonElement el; try { el = JsonSerializer.Deserialize(json); } catch { return false; } var type = el.TryGetProperty("type", out var t) ? t.GetString() : ""; if (type == "server_tool_use") { var id = el.TryGetProperty("id", out var idProp) ? idProp.GetString() : ""; return orphanedUseIds.Contains(id); } if (type == "web_search_tool_result") { var toolUseId = el.TryGetProperty("tool_use_id", out var tid) ? tid.GetString() : ""; return orphanedResultIds.Contains(toolUseId); } return false; }); } } /// /// Entfernt verwaiste tool_result-Blöcke aus user-Messages: /// tool_results deren tool_use_id in keinem vorherigen assistant-Block existiert. /// Tritt auf wenn eine ältere ask_user_choice-Chain als Text-only geladen wird – /// der tool_use geht verloren, aber das tool_result in der nächsten Chain bleibt. /// private static void FixOrphanedToolResults(List history) { // Alle tool_use IDs in der gesamten History sammeln var allToolUseIds = new HashSet(); foreach (var msg in history) { if (msg is not Dictionary dict) continue; if (!dict.TryGetValue("role", out var role) || role?.ToString() != "assistant") continue; if (!dict.TryGetValue("content", out var content) || content is not List contentList) continue; foreach (var block in contentList) { var json = JsonSerializer.Serialize(block); JsonElement el; try { el = JsonSerializer.Deserialize(json); } catch { continue; } if (!el.TryGetProperty("type", out var t) || t.GetString() != "tool_use") continue; if (el.TryGetProperty("id", out var idProp)) { var id = idProp.GetString(); if (!string.IsNullOrEmpty(id)) allToolUseIds.Add(id); } } } // User-Messages durchgehen und orphaned tool_results entfernen for (int i = history.Count - 1; i >= 0; i--) { if (history[i] is not Dictionary udict) continue; if (!udict.TryGetValue("role", out var urole) || urole?.ToString() != "user") continue; if (!udict.TryGetValue("content", out var ucontent) || ucontent is not List ucontentList) continue; ucontentList.RemoveAll(block => { var json = JsonSerializer.Serialize(block); JsonElement el; try { el = JsonSerializer.Deserialize(json); } catch { return false; } if (!el.TryGetProperty("type", out var t) || t.GetString() != "tool_result") return false; if (!el.TryGetProperty("tool_use_id", out var tid)) return false; var toolUseId = tid.GetString(); return !string.IsNullOrEmpty(toolUseId) && !allToolUseIds.Contains(toolUseId); }); // Wenn die user-Message nach dem Entfernen leer ist, ganze Message entfernen if (ucontentList.Count == 0) history.RemoveAt(i); } } /// /// Prüft die gesamte History auf tool_use-Blöcke ohne passendes tool_result in der nächsten user-Message. /// Falls gefunden, werden synthetische tool_results eingefügt – verhindert Anthropic API 400. /// Betrifft alle Tools, nicht nur ask_user_choice (z.B. wenn eine Chain abbricht ohne tool_result zu speichern). /// private static void FixOrphanedToolUse(List history) { if (history.Count == 0) return; for (int i = 0; i < history.Count; i++) { if (history[i] is not Dictionary assistantDict) continue; if (!assistantDict.TryGetValue("role", out var role) || role?.ToString() != "assistant") continue; if (!assistantDict.TryGetValue("content", out var content) || content is not List contentList) continue; // Alle tool_use IDs in dieser assistant-Message sammeln var toolUseIds = new List(); foreach (var block in contentList) { var json = JsonSerializer.Serialize(block); JsonElement el; try { el = JsonSerializer.Deserialize(json); } catch { continue; } if (!el.TryGetProperty("type", out var t) || t.GetString() != "tool_use") continue; if (!el.TryGetProperty("id", out var idProp)) continue; var id = idProp.GetString(); if (!string.IsNullOrEmpty(id)) toolUseIds.Add(id); } if (toolUseIds.Count == 0) continue; // Prüfen ob die nächste user-Message die tool_results enthält var coveredIds = new HashSet(); if (i + 1 < history.Count && history[i + 1] is Dictionary nextDict && nextDict.TryGetValue("role", out var nextRole) && nextRole?.ToString() == "user" && nextDict.TryGetValue("content", out var nextContent) && nextContent is List nextContentList) { foreach (var block in nextContentList) { var json = JsonSerializer.Serialize(block); JsonElement el; try { el = JsonSerializer.Deserialize(json); } catch { continue; } if (!el.TryGetProperty("type", out var t) || t.GetString() != "tool_result") continue; if (!el.TryGetProperty("tool_use_id", out var tid)) continue; var toolUseId = tid.GetString(); if (!string.IsNullOrEmpty(toolUseId)) coveredIds.Add(toolUseId); } } // Orphaned IDs = tool_use ohne passendes tool_result var orphanedIds = toolUseIds.Where(id => !coveredIds.Contains(id)).ToList(); if (orphanedIds.Count == 0) continue; // Synthetische tool_results bauen var syntheticResults = orphanedIds.Select(id => (object)new { type = "tool_result", tool_use_id = id, content = "Tool-Ergebnis nicht verfügbar (Chain wurde unterbrochen)." }).ToList(); // Falls nächste Message eine user-Message ist, synthetische results dort prependen if (i + 1 < history.Count && history[i + 1] is Dictionary existingUser && existingUser.TryGetValue("role", out var er) && er?.ToString() == "user" && existingUser.TryGetValue("content", out var ec) && ec is List existingContent) { existingContent.InsertRange(0, syntheticResults); } else { // Neue user-Message mit synthetischen results einfügen history.Insert(i + 1, new Dictionary { ["role"] = "user", ["content"] = syntheticResults }); i++; // Eingefügte Message überspringen } } } /// /// Baut einen SubAgentContext für Preparer/Review/Finalizer. /// Gemeinsame Logik: Agent laden, Model auflösen, SystemPrompt bauen, ToolChoice bestimmen. /// private async Task BuildSubAgentContext( string agentId, string sessionId, string workspace, SessionRecord session, UserRecord user) { if (string.IsNullOrEmpty(agentId)) return null; var agent = await _db.GetAgent(agentId); if (agent == null) return null; var modelId = ResolveModelId(agent.Model); var markedMessages = await _db.GetMarkedMessages(sessionId); var memDir = _memoryService.GetAgentMemoryDir(agent.AgentId); var skills = await _db.GetAgentSkills(agent.AgentId); var systemText = SystemPromptBuilder.Build( agent, session, markedMessages, _memoryService, memDir, skills, _skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace); var prompt = new List(); if (!string.IsNullOrWhiteSpace(systemText)) prompt.Add(new { type = "text", text = systemText }); var (provider, _) = OpenAiService.ParseModelString(modelId); var hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory; return new SubAgentContext { ModelId = modelId, Provider = provider, SystemPrompt = prompt, AgentId = agent.AgentId, Workspace = workspace, UseMemory = agent.UseMemory, WebSearchVersion = agent.WebSearchVersion, ToolChoice = hasTools ? "any" : "auto", Agent = agent }; } }