Files
gott/Services/ChatService.cs
T
Meleki dd99216913 fix: SSE-Stream-Stabilität und History-Trimming tool_use/tool_result-Schutz
- Kestrel MinResponseDataRate per-Request deaktivieren für SSE-Streams
  (Default 240 bytes/s + 5s Grace → client_disconnected bei langen Tools)
- SSE-Heartbeat alle 15s während Stream (schützt gegen Browser/Netzwerk-Idle)
- History-Trimming: RemoveRange schneidet nicht mehr in tool_use/tool_result-Paare
- Reminder-Insertion: kein Einfügen zwischen tool_use und tool_result
2026-03-09 20:13:22 +01:00

610 lines
27 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 int _maxHistory;
private readonly int _reminderInterval;
public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService,
int maxHistory = 120, int reminderInterval = 30)
{
_db = db;
_toolService = toolService;
_memoryService = memoryService;
_skillService = skillService;
_maxHistory = maxHistory;
_reminderInterval = reminderInterval;
}
public static string ResolveModelId(string model)
{
return model switch
{
"sonnet" => "claude-sonnet-4-6",
"sonnet-4-5" => "claude-sonnet-4-5",
"sonnet-4-6" => "claude-sonnet-4-6",
"opus" => "claude-opus-4-6",
"opus-4-5" => "claude-opus-4-5",
"opus-4-6" => "claude-opus-4-6",
"haiku" => "claude-haiku-4-5",
"haiku-4-5" => "claude-haiku-4-5",
_ => "claude-sonnet-4-6"
};
}
/// <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)
{
var session = await _db.GetSession(sessionId);
if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId))
return null;
var agent = await _db.GetAgent(session.AgentId);
if (agent == null)
return null;
var modelId = ResolveModelId(agent.Model);
var markedMessages = await _db.GetMarkedMessages(sessionId);
var agentMemoryDir = _memoryService.GetAgentMemoryDir(agent.AgentId);
var agentSkills = await _db.GetAgentSkills(agent.AgentId);
var user = userId != null ? await _db.GetUser(userId) : null;
var workspace = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workspace) && agentSkills.Any())
workspace = WorkspaceHelper.TempDir;
var promptBlocks = SystemPromptBuilder.BuildBlocks(
agent, session, markedMessages, _memoryService, agentMemoryDir,
agentSkills, _skillService, userPersona: user?.GetEffectivePersona(),
resolvedWorkspace: workspace, planMode: planMode);
var systemPromptText = string.Join("\n\n", promptBlocks.Select(b => b.Content));
var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
var hasTools = hasWorkspace || includeMemory;
var toolChoice = hasTools ? "any" : null;
var toolDefs = hasTools
? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice)
: 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;
return new ChatContext
{
ChainId = chainId,
SessionId = sessionId,
ModelId = modelId,
Workspace = workspace,
AgentId = agent.AgentId,
SystemPromptText = systemPromptText,
SystemPrompt = systemPrompt,
PromptBlocks = promptBlocks,
ToolChoice = toolChoice,
UseMemory = agent.UseMemory,
WebSearchVersion = agent.WebSearchVersion,
MaxIterations = maxIterations,
Agent = agent,
Session = session,
Preparer = preparerContext,
Review = reviewContext,
Finalizer = finalizerContext,
PlanMode = planMode
};
}
/// <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 aufeinanderfolgenden ask_user_choice-Runden entstehen mehrere Chains,
// die alle vollständig geladen werden müssen (tool_use + tool_result Paare).
// Eine Chain braucht die vorherige vollständig, wenn sie:
// - leer ist (gerade erst erstellt für Choice-Antwort)
// - mit einem tool_result beginnt (Antwort auf ask_user_choice der vorherigen Chain)
int fullChainStartIndex = chainsList.Count - 1;
for (int ci = chainsList.Count - 1; ci >= 1; ci--)
{
var chainMsgs = (await _db.GetChainMessages(chainsList[ci].ChainId)).ToList();
bool needsPrevious = chainMsgs.Count == 0 ||
chainMsgs.Any(m => m.Type == "tool_result" && m.Iteration == 999);
if (needsPrevious)
{
// Diese Chain enthält eine Choice-Antwort oder ist leer →
// vorherige Chain enthält den zugehörigen tool_use und muss vollständig geladen werden
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>("{}"); }
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(new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl });
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object>
{
new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }
}
});
}
}
else if (msg.Role == "assistant" && msg.Type == "server_tool_use")
{
// Server-Tool-Use (web_search, code_execution etc.) – Raw-JSON in Assistant-Content
JsonElement useEl;
try { useEl = JsonSerializer.Deserialize<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 == "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 = AnthropicService.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 = AnthropicService.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }
}
});
}
}
}
}
}
// History auf maximal N Messages begrenzen (älteste entfernen)
// WICHTIG: Nicht mitten in einem tool_use/tool_result-Paar abschneiden!
if (history.Count > _maxHistory)
{
int removeCount = history.Count - _maxHistory;
// Nach vorne korrigieren bis wir an einer sicheren Stelle sind:
// Nicht abschneiden wenn die erste verbleibende Message ein tool_result ist
// oder die letzte entfernte Message ein assistant mit tool_use ist
while (removeCount < history.Count - 1)
{
var firstRemaining = history[removeCount];
bool isToolResult = false;
if (firstRemaining is Dictionary<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);
}
}
return history;
}
/// <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
};
}
}