Files
gott/Services/ChatService.cs
T
MelekiandClaude Opus 4.6 1fe04025d3 fix(openai): Auto-Context, Tool-Result-Matching, Payload-Preview, leere Bubbles
- Auto-Context (Embedding-Suche) für OpenAI-Provider aktiviert: BuildAutoContextAsync
  auf internal static refactored und in StreamChat + ChatNonStreaming integriert
- Tool-Results mit Role="tool" (OpenAI-Konvention) werden in BuildHistoryFromDb
  jetzt korrekt erkannt (vorher nur Role="user"/Anthropic)
- Payload-Preview zeigt für OpenAI-Provider echtes API-Format (function-Wrapper,
  tool_choice:"auto", max_tokens, stream_options, extra_body)
- Tool-Input (Argumente) wird im SSE tool_end-Event mitgesendet (fehlte im OpenAI-Pfad)
- Leere Bubbles durch Whitespace-only Text nach </think>-Extraktion behoben
  (Trim in DB-Save, BuildHistoryFromDb und Frontend-Guard)
- History-Toggle-Bug: var→const in for-Loop (Block-Scoping für Closures),
  tool_result-Matching per data-tool-call-id statt blindes letztes Element
- Non-Streaming max_tokens für MiniMax auf 32768 + extra_body.reasoning_split

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 11:16:40 +02:00

887 lines
41 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);
public static string ResolveModelId(string model)
{
// OpenAI-kompatible Provider werden unverändert durchgereicht (Format: "provider:model")
if (OpenAiService.IsOpenAiProvider(model)) return model;
return model switch
{
"sonnet" => "claude-sonnet-4-6",
"sonnet[1m]" => "claude-sonnet-4-6[1m]",
"sonnet-4-5" => "claude-sonnet-4-5",
"sonnet-4-6" => "claude-sonnet-4-6",
"sonnet-4-6[1m]" => "claude-sonnet-4-6[1m]",
"opus" => "claude-opus-4-7",
"opus[1m]" => "claude-opus-4-7[1m]",
"opus-4-5" => "claude-opus-4-5",
"opus-4-6" => "claude-opus-4-6",
"opus-4-6[1m]" => "claude-opus-4-6[1m]",
"opus-4-7" => "claude-opus-4-7",
"opus-4-7[1m]" => "claude-opus-4-7[1m]",
"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
});
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);
if (preparerContext != null)
{
preparerContext.AllowUserChoice = preparerContext.Agent?.AllowUserChoice ?? false;
preparerContext.SystemPrompt.Add(new { type = "text", text = @"## KRITISCH: Deine Rolle als Vorbereiter-Agent
Du bist ein Vorbereiter-Agent. Deine Aufgabe: Die Anfrage des Users aufbereiten, deine Fähigkeiten nutzen, und das Ergebnis an den Session-Agent weiterleiten.
### Standardverhalten: Automatische Weiterleitung
Deine Antwort wird AUTOMATISCH an den Session-Agent weitergeleitet. Du musst nichts Besonderes tun – antworte einfach mit dem aufbereiteten Ergebnis. Optional kannst du `[FORWARD]` an den Anfang schreiben, aber es ist nicht nötig.
### Ausnahme: Rückfrage an den User
NUR wenn du eine echte Rückfrage hast (Anfrage ist unklar/mehrdeutig), schreibe `[QUESTION]` an den Anfang deiner Antwort. Dann wird deine Antwort dem User als Rückfrage angezeigt und NICHT an den Session-Agent weitergeleitet.
### Nutze deine Fähigkeiten aktiv!
Du hast eigene Tools. Nutze sie! Erledige was du kannst (Dateien lesen, Daten aufbereiten, Strukturen erstellen, recherchieren). Der Session-Agent soll ein möglichst gut vorbereitetes Paket bekommen.
### Beispiele:
Weiterleitung (wird automatisch an den Session-Agent übergeben):
```
Der User möchte Produktdaten als JSON. Ich habe es bereits erstellt:
{""products"": [{""id"": 1, ""name"": ""Widget""}]}
Bitte speichere es in products.json.
```
Rückfrage (NUR mit [QUESTION] – stoppt die Weiterleitung):
```
[QUESTION]
Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
```" });
}
if (reviewContext != null)
reviewContext.MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3;
var (provider, _) = OpenAiService.ParseModelString(modelId);
return new ChatContext
{
ChainId = chainId,
SessionId = sessionId,
ModelId = modelId,
Provider = provider,
Workspace = workspace,
AgentId = agent.AgentId,
SystemPromptText = systemPromptText,
SystemPrompt = systemPrompt,
PromptBlocks = promptBlocks,
ToolChoice = toolChoice,
UseMemory = agent.UseMemory,
WebSearchVersion = agent.WebSearchVersion,
MaxIterations = maxIterations,
Agent = agent,
Session = session,
Preparer = preparerContext,
Review = reviewContext,
Finalizer = finalizerContext,
PlanMode = planMode
};
}
/// <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" && !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 == "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",
Agent = agent
};
}
}