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);
// Rollen-Klarstellung für Panel-Member (Fusion/MoA). Member laufen READ-ONLY (PlanMode bzw.
// tool-los). Ohne diesen Hinweis behält jeder Member seinen normalen Agent-Prompt und versucht
// oft Schreib-/Execute-/git-Aktionen, die gefiltert werden → er "beißt sich fest" in wiederholten,
// fehlschlagenden Tool-Versuchen. Der Hinweis lenkt ihn auf reine Analyse/Vorschlag; das tatsächliche
// Umsetzen (Schreiben, Ausführen, Committen) übernimmt der Synthese-Agent (Session-Worker).
private const string PanelMemberRolePrompt = @"## KRITISCH: Deine Rolle als Panel-Agent (Proposer)
Du bist einer von mehreren KI-Agenten, die PARALLEL und UNABHÄNGIG dieselbe Anfrage beantworten. Aus euren Antworten synthetisiert anschließend ein separater Agent EINE Gesamtantwort für den User.
### Du arbeitest READ-ONLY
Dir stehen NUR lesende Tools zur Verfügung (Dateien lesen, suchen, auflisten). Schreibende oder ausführende Aktionen – Dateien anlegen/ändern/löschen, Shell-/Build-Befehle, git/commit, Deployment – sind für dich DEAKTIVIERT und werden blockiert. Versuche sie GAR NICHT ERST: Du kommst damit nicht weiter, verschwendest nur Zeit und blockierst dich selbst in fehlschlagenden Tool-Versuchen.
### Deine Aufgabe
Liefere deinen besten VORSCHLAG als Text-Antwort: Analyse, Lösung, konkrete Code-Vorschläge, Begründung. Nutze die lesenden Tools, um dich am echten Stand zu orientieren – aber führe nichts aus und ändere nichts. Beschreibe stattdessen klar, WAS zu tun ist. Das tatsächliche Umsetzen übernimmt der Synthese-Agent.
### Wichtig
Nur deine finale Text-Antwort wird weiterverwendet. Formuliere sie vollständig und eigenständig, sodass sie auch ohne deine Tool-Schritte verständlich ist. Antworte zügig und direkt – kein Schreib-/Commit-Versuch.";
public static string ResolveModelId(string model)
{
// OpenAI-kompatible Provider werden unverändert durchgereicht (Format: "provider:model")
if (OpenAiService.IsOpenAiProvider(model)) return model;
// z.ai / GLM laufen über den Anthropic-kompatiblen Claude-CLI-Pfad (NICHT über den
// OpenAI-Provider). Prefix strippen → die rohe Modell-ID (z.B. "glm-4.6") geht an --model,
// und das Routing erkennt Provider="" → CLI. Die z.ai-Env-Vars setzt ClaudeCliService.
if (!string.IsNullOrEmpty(model) && model.StartsWith("glm:", System.StringComparison.OrdinalIgnoreCase))
return model.Substring(4);
// 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",
"sonnet-5" => "claude-sonnet-5",
"opus" => "claude-opus-4-8",
"opus-3" => "claude-opus-3",
"opus-4-5" => "claude-opus-4-5",
"opus-4-6" => "claude-opus-4-6",
"opus-4-7" => "claude-opus-4-7",
"opus-4-8" => "claude-opus-4-8",
"opus-5" => "claude-opus-5",
"fable" => "claude-fable-5",
"fable-5" => "claude-fable-5",
"haiku" => "claude-haiku-4-5",
"haiku-4-5" => "claude-haiku-4-5",
_ => "claude-sonnet-5"
};
}
///
/// 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, string multiModelMode = null, 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,
Effort = OpenAiService.IsOpenAiProvider(modelId) ? "" : agent.Effort
});
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);
// Multi-Modell-Panel (Fusion/MoA): Panel-Agenten (Proposer) bauen, falls aktiviert.
// Der Modus ist on-demand (transient pro Request, wie Plan-/Ultracode-Modus) → er kommt
// aus dem Request-Flag, NICHT mehr aus der Session. Die Panel-Mitglieder bleiben persistent
// (session.PanelAgentIds), nur ob/wie das Panel läuft entscheidet die einzelne Anfrage.
// Nur "fusion"/"moa" sind gültig; alles andere (leer/"off"/Müll) → kein Panel.
var effectiveMultiModelMode = (multiModelMode == "fusion" || multiModelMode == "moa") ? multiModelMode : "off";
var panelContexts = new List();
if (effectiveMultiModelMode != "off"
&& !string.IsNullOrWhiteSpace(session.PanelAgentIds))
{
var panelIds = session.PanelAgentIds
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct();
foreach (var pid in panelIds)
{
var pc = await BuildSubAgentContext(pid, sessionId, workspace, session, user);
if (pc != null)
{
// Proposer-Rolle klarstellen (read-only) → Member versuchen nicht mehr vergeblich
// zu schreiben/committen und "beißen sich fest". Gilt für alle Provider-Pfade
// (Anthropic nutzt SystemPrompt direkt, CLI/OpenAI via ExtractSystemPromptText).
pc.SystemPrompt.Add(new { type = "text", text = PanelMemberRolePrompt });
panelContexts.Add(pc);
}
}
}
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,
Effort = agent.Effort,
MaxOutputTokens = agent.MaxOutputTokens,
DisableThinking = agent.DisableThinking,
MaxIterations = maxIterations,
Agent = agent,
Session = session,
Preparer = preparerContext,
Review = reviewContext,
Finalizer = finalizerContext,
MultiModelMode = effectiveMultiModelMode,
PanelAgents = panelContexts,
MoaLayers = session.MoaLayers,
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" || msg.Type == "steer") && !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 == "user" && msg.Type == "steer")
{
// Live-Einwurf (Steering) des Users während die Chain lief.
// AppendToLastMessage mergt ihn in den vorausgehenden user-Turn
// (tool_result-Runde bei Drain-B) bzw. erzeugt einen neuen user-Turn
// nach dem Assistant (Drain-C) → niemals zwei aufeinanderfolgende user-Turns.
if (!string.IsNullOrEmpty(msg.Content))
AppendToLastMessage(history, "user", new { type = "text", text = msg.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",
Effort = agent.Effort,
Agent = agent
};
}
}