- Neuer ServerSettingsService: zentrale Settings in SQLite mit In-Memory-Cache - Auto-Migration: bestehende appsettings.Production.json wird einmalig in DB übernommen - Alle Services umgestellt: IConfiguration → ServerSettingsService (dynamisch, kein Neustart) - Port bleibt als einziges Setting in appsettings.Production.json (erfordert Neustart) - ServerSettingsEndpoint: liest/schreibt DB statt JSON-Datei, Port als Sonder-Setting - IConfiguration komplett entfernt aus allen Services (ChatService, ToolService, AnthropicService, OpenAiService, KreaService, TelegramService, KeyScannerService, GitHubEventScannerService) - AuthMiddleware: KreaApiKey dynamisch aus DB - appsettings.json bereinigt: nur noch Port und Logging - Dokumentation aktualisiert (ARCHITECTURE.md, ARCHITECTURE_QUICK.md, README.md)
824 lines
38 KiB
C#
824 lines
38 KiB
C#
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", 120);
|
||
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-4-5" => "claude-sonnet-4-5",
|
||
"sonnet-4-6" => "claude-sonnet-4-6",
|
||
"opus" => "claude-opus-4-6",
|
||
"opus-4-5" => "claude-opus-4-5",
|
||
"opus-4-6" => "claude-opus-4-6",
|
||
"haiku" => "claude-haiku-4-5",
|
||
"haiku-4-5" => "claude-haiku-4-5",
|
||
_ => "claude-sonnet-4-6"
|
||
};
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
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(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice, includeRemote: hasRemoteConnection, 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)
|
||
{
|
||
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.IsNullOrEmpty(msg.Content))
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { new { type = "text", text = msg.Content } }
|
||
});
|
||
}
|
||
}
|
||
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 == "text")
|
||
{
|
||
if (!string.IsNullOrEmpty(msg.Content))
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { new { type = "text", text = msg.Content } }
|
||
});
|
||
}
|
||
}
|
||
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 };
|
||
|
||
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
|
||
if (lastAssistant is Dictionary<string, object> dict &&
|
||
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
|
||
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
|
||
{
|
||
contentList.Add(toolUseBlock);
|
||
}
|
||
else
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { 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; }
|
||
|
||
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
|
||
if (lastAssistant is Dictionary<string, object> dict &&
|
||
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
|
||
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
|
||
{
|
||
contentList.Add(useEl);
|
||
}
|
||
else
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { 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; }
|
||
|
||
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
|
||
if (lastAssistant is Dictionary<string, object> dict &&
|
||
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
|
||
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
|
||
{
|
||
contentList.Add(resultEl);
|
||
}
|
||
else
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { 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;
|
||
|
||
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
|
||
if (lastAssistant is Dictionary<string, object> dict &&
|
||
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
|
||
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
|
||
{
|
||
contentList.Add(resultEl);
|
||
}
|
||
else
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { resultEl }
|
||
});
|
||
}
|
||
}
|
||
else if (msg.Role == "user" && msg.Type == "tool_result")
|
||
{
|
||
var lastUser = history.Count > 0 ? history[history.Count - 1] : null;
|
||
if (lastUser is Dictionary<string, object> dict &&
|
||
dict.TryGetValue("role", out var role) && role.ToString() == "user" &&
|
||
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
|
||
{
|
||
contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError });
|
||
}
|
||
else
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "user",
|
||
["content"] = new List<object>
|
||
{
|
||
new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// History auf maximal N Messages begrenzen (älteste entfernen)
|
||
// WICHTIG: Nicht mitten in einem tool_use/tool_result-Paar abschneiden!
|
||
if (history.Count > MaxHistory)
|
||
{
|
||
int removeCount = history.Count - MaxHistory;
|
||
// Nach vorne korrigieren bis wir an einer sicheren Stelle sind:
|
||
// Nicht abschneiden wenn die erste verbleibende Message ein tool_result ist
|
||
// oder die letzte entfernte Message ein assistant mit tool_use ist
|
||
while (removeCount < history.Count - 1)
|
||
{
|
||
var firstRemaining = history[removeCount];
|
||
bool isToolResult = false;
|
||
if (firstRemaining is Dictionary<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: alle ~30 user/assistant-Paare ein Reminder-Paar einfügen
|
||
var reminder = SystemPromptBuilder.BuildReminder(promptBlocks);
|
||
if (!string.IsNullOrWhiteSpace(reminder) && ReminderInterval > 0 && history.Count > ReminderInterval)
|
||
{
|
||
var reminderUser = new Dictionary<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." } }
|
||
};
|
||
|
||
// Alle 30 Messages ein Reminder-Paar einfügen (von hinten nach vorne, damit Indizes stimmen)
|
||
var insertPositions = new List<int>();
|
||
int messageCount = 0;
|
||
for (int i = 0; i < history.Count; i++)
|
||
{
|
||
messageCount++;
|
||
if (messageCount > 0 && messageCount % ReminderInterval == 0)
|
||
{
|
||
// Position nach dem aktuellen Element – aber nur einfügen wenn es eine
|
||
// assistant-Message ist (damit user/assistant-Alternierung nicht bricht)
|
||
// UND die nächste Message kein tool_result ist (sonst bricht tool_use/tool_result-Paar)
|
||
bool nextIsToolResult = false;
|
||
if (i + 1 < history.Count && history[i + 1] is Dictionary<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 && history[i] is Dictionary<string, object> d &&
|
||
d.TryGetValue("role", out var r) && r.ToString() == "assistant")
|
||
{
|
||
insertPositions.Add(i + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Von hinten nach vorne einfügen, damit Indizes nicht verschoben werden
|
||
for (int j = insertPositions.Count - 1; j >= 0; j--)
|
||
{
|
||
var pos = insertPositions[j];
|
||
history.Insert(pos, reminderAssistant);
|
||
history.Insert(pos, reminderUser);
|
||
}
|
||
}
|
||
|
||
// Post-Processing: Offene tool_use-Blöcke ohne tool_result → synthetische tool_results einfügen
|
||
FixOrphanedToolUse(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;
|
||
}
|
||
|
||
/// <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>
|
||
/// 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 hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory;
|
||
return new SubAgentContext
|
||
{
|
||
ModelId = modelId,
|
||
SystemPrompt = prompt,
|
||
AgentId = agent.AgentId,
|
||
Workspace = workspace,
|
||
UseMemory = agent.UseMemory,
|
||
WebSearchVersion = agent.WebSearchVersion,
|
||
ToolChoice = hasTools ? "any" : "auto",
|
||
Agent = agent
|
||
};
|
||
}
|
||
|
||
}
|