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 int _maxHistory; private readonly int _reminderInterval; public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService, int maxHistory = 120, int reminderInterval = 30) { _db = db; _toolService = toolService; _memoryService = memoryService; _skillService = skillService; _maxHistory = maxHistory; _reminderInterval = reminderInterval; } public static string ResolveModelId(string model) { 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-6", "opus-4-5" => "claude-opus-4-5", "opus-4-6" => "claude-opus-4-6", "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) { 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 hasTools = hasWorkspace || includeMemory; var toolChoice = hasTools ? "any" : null; var toolDefs = hasTools ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice, includeRemote: hasRemoteConnection) : 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; return new ChatContext { ChainId = chainId, SessionId = sessionId, ModelId = modelId, 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) { 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.IsNullOrEmpty(msg.Content)) { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { new { type = "text", text = msg.Content } } }); } } 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 == "text") { if (!string.IsNullOrEmpty(msg.Content)) { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { new { type = "text", text = msg.Content } } }); } } else if (msg.Role == "assistant" && msg.Type == "tool_use") { JsonElement inputEl; try { inputEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { inputEl = JsonSerializer.Deserialize("{}"); } var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; if (lastAssistant is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }); } else { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl } } }); } } 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; } var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; if (lastAssistant is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(useEl); } else { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { 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; } var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; if (lastAssistant is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(resultEl); } else { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { 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; var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; if (lastAssistant is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(resultEl); } else { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { resultEl } }); } } else if (msg.Role == "user" && msg.Type == "tool_result") { var lastUser = history.Count > 0 ? history[history.Count - 1] : null; if (lastUser is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "user" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = AnthropicService.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }); } else { history.Add(new Dictionary { ["role"] = "user", ["content"] = new List { new { type = "tool_result", tool_use_id = msg.ToolCallId, content = AnthropicService.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError } } }); } } } } } // 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: alle ~30 user/assistant-Paare ein Reminder-Paar einfügen var reminder = SystemPromptBuilder.BuildReminder(promptBlocks); if (!string.IsNullOrWhiteSpace(reminder) && _reminderInterval > 0 && history.Count > _reminderInterval) { 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." } } }; // Alle 30 Messages ein Reminder-Paar einfügen (von hinten nach vorne, damit Indizes stimmen) var insertPositions = new List(); int messageCount = 0; for (int i = 0; i < history.Count; i++) { messageCount++; if (messageCount > 0 && messageCount % _reminderInterval == 0) { // Position nach dem aktuellen Element – aber nur einfügen wenn es eine // assistant-Message ist (damit user/assistant-Alternierung nicht bricht) // UND die nächste Message kein tool_result ist (sonst bricht tool_use/tool_result-Paar) 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 && history[i] is Dictionary d && d.TryGetValue("role", out var r) && r.ToString() == "assistant") { insertPositions.Add(i + 1); } } } // Von hinten nach vorne einfügen, damit Indizes nicht verschoben werden for (int j = insertPositions.Count - 1; j >= 0; j--) { var pos = insertPositions[j]; history.Insert(pos, reminderAssistant); history.Insert(pos, reminderUser); } } // 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; } /// /// 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; }); } } /// /// 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 hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory; return new SubAgentContext { ModelId = modelId, SystemPrompt = prompt, AgentId = agent.AgentId, Workspace = workspace, UseMemory = agent.UseMemory, WebSearchVersion = agent.WebSearchVersion, ToolChoice = hasTools ? "any" : "auto", Agent = agent }; } }