Files
gott/Services/ChatService.cs
T
MelekiandClaude Opus 4.8 0f3d658fd3 Add z.ai/GLM provider via per-process Claude CLI env injection
Route glm:-prefixed models through the CLI/Anthropic path: ResolveModelId
strips the glm: prefix, and CreateCliStartInfo activates the z.ai
Anthropic-compatible endpoint (ANTHROPIC_BASE_URL/AUTH_TOKEN) only for that
process when IsZaiModel matches, leaving every other request's OAuth login
untouched. GLM model list is loaded through the existing model cache and
exposed via /api/models/glm with a "GLM (z.ai)" optgroup in the agent form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:01:43 +02:00

952 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
"opus" => "claude-opus-4-8",
"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",
"fable" => "claude-fable-5",
"fable-5" => "claude-fable-5",
"haiku" => "claude-haiku-4-5",
"haiku-4-5" => "claude-haiku-4-5",
_ => "claude-sonnet-4-6"
};
}
/// <summary>
/// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc.
/// </summary>
public async Task<ChatContext> 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<object>();
var toolNames = toolDefs.Length > 0
? JsonSerializer.Serialize(toolDefs.Select(td =>
{
var json = JsonSerializer.Serialize(td);
var el = JsonSerializer.Deserialize<JsonElement>(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<object>();
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): Panel-Agenten (Proposer) bauen, falls aktiviert.
// Jeder Panel-Agent ist ein vollwertiger SubAgentContext (Modell/Provider/Key/Prompt/Effort).
var panelContexts = new List<SubAgentContext>();
if (!string.IsNullOrEmpty(session.MultiModelMode) && session.MultiModelMode != "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,
MaxIterations = maxIterations,
Agent = agent,
Session = session,
Preparer = preparerContext,
Review = reviewContext,
Finalizer = finalizerContext,
MultiModelMode = session.MultiModelMode,
PanelAgents = panelContexts,
MoaLayers = session.MoaLayers,
PlanMode = planMode
};
}
/// <summary>
/// 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.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <summary>
/// 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).
/// </summary>
public async Task<List<object>> BuildHistoryFromDb(string sessionId, string includeTag = null, List<PromptBlock> promptBlocks = null, bool includeThinking = false)
{
var chainsList = (await _db.GetActiveChains(sessionId)).ToList();
// chronologisch sortieren (älteste zuerst)
chainsList.Reverse();
var history = new List<object>();
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<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrWhiteSpace(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = msg.Content.Trim() } }
});
}
}
continue;
}
// Letzte Chain: vollständig mit Tool-Calls
var grouped = new Dictionary<int, List<MessageRecord>>();
foreach (var msg in msgs)
{
if (!grouped.ContainsKey(msg.Iteration))
grouped[msg.Iteration] = new List<MessageRecord>();
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<object>();
// 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<string, object> { ["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 <think>...</think>
// dem Assistant-Text vorangestellt (laut MiniMax-Docs: "the content field
// will contain <think> 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<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "thinking", thinking = msg.Content } }
});
}
}
else if (msg.Role == "assistant" && msg.Type == "text")
{
// Trim: Nach </think>-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<JsonElement>(msg.Content ?? "{}"); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
// 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<JsonElement>(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<JsonElement>(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<JsonElement>(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<string, object> frDict &&
frDict.TryGetValue("role", out var frRole) && frRole.ToString() == "user" &&
frDict.TryGetValue("content", out var frContent) && frContent is List<object> 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<string, object> lrDict &&
lrDict.TryGetValue("role", out var lrRole) && lrRole.ToString() == "assistant" &&
lrDict.TryGetValue("content", out var lrContent) && lrContent is List<object> 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<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = "[System Reminder] " + reminder } }
};
var reminderAssistant = new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { 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>();
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<int>();
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<string, object> d &&
d.TryGetValue("role", out var r) && r.ToString() == "assistant"))
continue;
bool nextIsToolResult = false;
if (i + 1 < history.Count && history[i + 1] is Dictionary<string, object> nextDict &&
nextDict.TryGetValue("role", out var nextRole) && nextRole.ToString() == "user" &&
nextDict.TryGetValue("content", out var nextContent) && nextContent is List<object> 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<object> history, string role, object block)
{
var last = history.Count > 0 ? history[history.Count - 1] : null;
if (last is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var r) && r.ToString() == role &&
dict.TryGetValue("content", out var existing) && existing is List<object> contentList)
{
contentList.Add(block);
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = role,
["content"] = new List<object> { block }
});
}
}
/// <summary>
/// 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)
/// </summary>
private static void CleanOrphanedWebSearchBlocks(List<object> history)
{
foreach (var msg in history)
{
if (msg is not Dictionary<string, object> dict) continue;
if (!dict.TryGetValue("role", out var role) || role.ToString() != "assistant") continue;
if (!dict.TryGetValue("content", out var content) || content is not List<object> contentList) continue;
// Prüfe ob es web_search server_tool_use gibt
var webSearchToolUseIds = new HashSet<string>();
var webSearchToolResultIds = new HashSet<string>();
foreach (var block in contentList)
{
var json = JsonSerializer.Serialize(block);
JsonElement el;
try { el = JsonSerializer.Deserialize<JsonElement>(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<JsonElement>(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;
});
}
}
/// <summary>
/// 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.
/// </summary>
private static void FixOrphanedToolResults(List<object> history)
{
// Alle tool_use IDs in der gesamten History sammeln
var allToolUseIds = new HashSet<string>();
foreach (var msg in history)
{
if (msg is not Dictionary<string, object> dict) continue;
if (!dict.TryGetValue("role", out var role) || role?.ToString() != "assistant") continue;
if (!dict.TryGetValue("content", out var content) || content is not List<object> contentList) continue;
foreach (var block in contentList)
{
var json = JsonSerializer.Serialize(block);
JsonElement el;
try { el = JsonSerializer.Deserialize<JsonElement>(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<string, object> udict) continue;
if (!udict.TryGetValue("role", out var urole) || urole?.ToString() != "user") continue;
if (!udict.TryGetValue("content", out var ucontent) || ucontent is not List<object> ucontentList) continue;
ucontentList.RemoveAll(block =>
{
var json = JsonSerializer.Serialize(block);
JsonElement el;
try { el = JsonSerializer.Deserialize<JsonElement>(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);
}
}
/// <summary>
/// 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).
/// </summary>
private static void FixOrphanedToolUse(List<object> history)
{
if (history.Count == 0) return;
for (int i = 0; i < history.Count; i++)
{
if (history[i] is not Dictionary<string, object> assistantDict) continue;
if (!assistantDict.TryGetValue("role", out var role) || role?.ToString() != "assistant") continue;
if (!assistantDict.TryGetValue("content", out var content) || content is not List<object> contentList) continue;
// Alle tool_use IDs in dieser assistant-Message sammeln
var toolUseIds = new List<string>();
foreach (var block in contentList)
{
var json = JsonSerializer.Serialize(block);
JsonElement el;
try { el = JsonSerializer.Deserialize<JsonElement>(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<string>();
if (i + 1 < history.Count &&
history[i + 1] is Dictionary<string, object> nextDict &&
nextDict.TryGetValue("role", out var nextRole) && nextRole?.ToString() == "user" &&
nextDict.TryGetValue("content", out var nextContent) && nextContent is List<object> nextContentList)
{
foreach (var block in nextContentList)
{
var json = JsonSerializer.Serialize(block);
JsonElement el;
try { el = JsonSerializer.Deserialize<JsonElement>(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<string, object> existingUser &&
existingUser.TryGetValue("role", out var er) && er?.ToString() == "user" &&
existingUser.TryGetValue("content", out var ec) && ec is List<object> existingContent)
{
existingContent.InsertRange(0, syntheticResults);
}
else
{
// Neue user-Message mit synthetischen results einfügen
history.Insert(i + 1, new Dictionary<string, object>
{
["role"] = "user",
["content"] = syntheticResults
});
i++; // Eingefügte Message überspringen
}
}
}
/// <summary>
/// Baut einen SubAgentContext für Preparer/Review/Finalizer.
/// Gemeinsame Logik: Agent laden, Model auflösen, SystemPrompt bauen, ToolChoice bestimmen.
/// </summary>
private async Task<SubAgentContext> 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<object>();
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
};
}
}