- MessageRecord: neues Tag-Feld (null=normal, preparer/review/finalizer=Sub-Agent) - DatabaseService: Migration tag-Spalte, INSERT-Statements erweitert - AnthropicService: RunAgentChat setzt Tag=agentType bei allen 4 SaveMessage-Aufrufen - ChatService: BuildHistoryFromDb filtert getaggte Messages aus API-History Sub-Agent-Messages bleiben in der DB (Frontend-Anzeige), werden aber nie an die Claude API geschickt – löst das Problem für Preparer, Review und Finalizer gleichermaßen.
481 lines
22 KiB
C#
481 lines
22 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;
|
||
|
||
public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService)
|
||
{
|
||
_db = db;
|
||
_toolService = toolService;
|
||
_memoryService = memoryService;
|
||
_skillService = skillService;
|
||
}
|
||
|
||
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 = "/tmp";
|
||
|
||
var systemPromptText = SystemPromptBuilder.Build(
|
||
agent, session, markedMessages, _memoryService, agentMemoryDir,
|
||
agentSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
||
resolvedWorkspace: workspace, planMode: planMode);
|
||
|
||
var toolChoice = agent.ToolChoice ?? "any";
|
||
var hasWorkspace = !string.IsNullOrEmpty(workspace);
|
||
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
|
||
|
||
var toolDefs = (hasWorkspace || includeMemory)
|
||
? _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 = (hasWorkspace || includeMemory) ? toolChoice : null,
|
||
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 });
|
||
|
||
// Preparer-Agent vorbereiten (falls konfiguriert)
|
||
PreparerContext preparerContext = null;
|
||
if (!string.IsNullOrEmpty(session.PreparerAgentId))
|
||
{
|
||
var preparerAgent = await _db.GetAgent(session.PreparerAgentId);
|
||
if (preparerAgent != null)
|
||
{
|
||
var preparerModelId = ResolveModelId(preparerAgent.Model);
|
||
var preparerMarked = await _db.GetMarkedMessages(sessionId);
|
||
var preparerMemDir = _memoryService.GetAgentMemoryDir(preparerAgent.AgentId);
|
||
var preparerSkills = await _db.GetAgentSkills(preparerAgent.AgentId);
|
||
var preparerWorkspace = workspace;
|
||
var preparerSystemText = SystemPromptBuilder.Build(
|
||
preparerAgent, session, preparerMarked, _memoryService, preparerMemDir,
|
||
preparerSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
||
resolvedWorkspace: preparerWorkspace);
|
||
|
||
var preparerPrompt = new List<object>();
|
||
if (!string.IsNullOrWhiteSpace(preparerSystemText))
|
||
preparerPrompt.Add(new { type = "text", text = preparerSystemText });
|
||
|
||
// Preparer-Instruktion: erklärt [FORWARD]-Mechanismus
|
||
preparerPrompt.Add(new { type = "text", text = @"## Deine Rolle als Vorbereiter-Agent
|
||
Du bist ein Vorbereiter-Agent. Deine Aufgabe ist es, die Anfrage des Users zu analysieren, vorzubereiten und – wenn möglich – Teile der Aufgabe bereits selbst zu erledigen, BEVOR das Ergebnis an den eigentlichen Session-Agent weitergegeben wird.
|
||
|
||
### Nutze deine Fähigkeiten aktiv!
|
||
Du hast eigene Fähigkeiten und Tools. Nutze sie! Wenn du Teile der Aufgabe selbst erledigen kannst (z.B. Dateien lesen, Daten aufbereiten, Strukturen erstellen, Recherchen durchführen), dann tu das BEVOR du weiterleitest. Der Session-Agent soll von dir ein möglichst gut vorbereitetes Paket bekommen.
|
||
|
||
### Dein Ablauf:
|
||
1. **Analysiere** die User-Anfrage
|
||
2. **Stelle Rückfragen**, wenn die Anfrage unklar, unvollständig oder mehrdeutig ist (antworte einfach mit deiner Frage als normaler Text)
|
||
3. **Erledige** was du kannst – nutze deine Fähigkeiten und Tools, um Vorarbeit zu leisten
|
||
4. **Leite weiter** mit dem Ergebnis deiner Arbeit
|
||
|
||
### Weiterleitung:
|
||
Wenn du fertig bist, schreibe `[FORWARD]` gefolgt von dem aufbereiteten Ergebnis/Prompt, der an den Session-Agent weitergegeben werden soll. Alles nach `[FORWARD]` wird als Prompt an den Session-Agent übergeben.
|
||
|
||
Beispiel – Anfrage nur verfeinert:
|
||
```
|
||
[FORWARD]
|
||
Der User möchte eine REST-API mit folgenden Endpunkten erstellen:
|
||
- GET /users – Liste aller User
|
||
- POST /users – Neuen User anlegen
|
||
Technologie: .NET 10, SQLite
|
||
```
|
||
|
||
Beispiel – Vorarbeit geleistet:
|
||
```
|
||
[FORWARD]
|
||
Der User möchte Produktdaten als JSON. Ich habe das JSON-Objekt bereits erstellt:
|
||
{""products"": [{""id"": 1, ""name"": ""Widget""}, {""id"": 2, ""name"": ""Gadget""}]}
|
||
Bitte speichere es in die Datei products.json im Workspace.
|
||
```
|
||
|
||
### Wichtige Regeln:
|
||
- Antworte OHNE `[FORWARD]` wenn du Rückfragen hast → der User sieht deine Antwort und kann darauf reagieren
|
||
- Antworte MIT `[FORWARD]` wenn du fertig bist → der Text nach [FORWARD] wird an den Session-Agent weitergegeben
|
||
- Nutze deine Tools und Fähigkeiten aktiv, um dem Session-Agent möglichst viel Arbeit abzunehmen
|
||
- Sei effizient: Wenn die Anfrage offensichtlich klar ist und du nichts vorbereiten kannst, leite sofort weiter" });
|
||
|
||
preparerContext = new PreparerContext
|
||
{
|
||
ModelId = preparerModelId,
|
||
SystemPrompt = preparerPrompt,
|
||
AgentId = preparerAgent.AgentId,
|
||
Workspace = preparerWorkspace,
|
||
UseMemory = preparerAgent.UseMemory,
|
||
WebSearchVersion = preparerAgent.WebSearchVersion,
|
||
ToolChoice = preparerAgent.ToolChoice ?? "any",
|
||
AllowUserChoice = preparerAgent.AllowUserChoice,
|
||
Agent = preparerAgent
|
||
};
|
||
}
|
||
}
|
||
|
||
// Review-Agent vorbereiten (falls konfiguriert)
|
||
ReviewContext reviewContext = null;
|
||
if (!string.IsNullOrEmpty(session.ReviewAgentId))
|
||
{
|
||
var reviewAgent = await _db.GetAgent(session.ReviewAgentId);
|
||
if (reviewAgent != null)
|
||
{
|
||
var reviewModelId = ResolveModelId(reviewAgent.Model);
|
||
var reviewMarked = await _db.GetMarkedMessages(sessionId);
|
||
var reviewMemDir = _memoryService.GetAgentMemoryDir(reviewAgent.AgentId);
|
||
var reviewSkills = await _db.GetAgentSkills(reviewAgent.AgentId);
|
||
var reviewWorkspace = workspace; // gleicher Workspace wie Worker
|
||
var reviewSystemText = SystemPromptBuilder.Build(
|
||
reviewAgent, session, reviewMarked, _memoryService, reviewMemDir,
|
||
reviewSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
||
resolvedWorkspace: reviewWorkspace);
|
||
|
||
var reviewPrompt = new List<object>();
|
||
if (!string.IsNullOrWhiteSpace(reviewSystemText))
|
||
reviewPrompt.Add(new { type = "text", text = reviewSystemText });
|
||
|
||
reviewContext = new ReviewContext
|
||
{
|
||
ModelId = reviewModelId,
|
||
SystemPrompt = reviewPrompt,
|
||
AgentId = reviewAgent.AgentId,
|
||
Workspace = reviewWorkspace,
|
||
UseMemory = reviewAgent.UseMemory,
|
||
WebSearchVersion = reviewAgent.WebSearchVersion,
|
||
ToolChoice = reviewAgent.ToolChoice ?? "any",
|
||
MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3,
|
||
Agent = reviewAgent
|
||
};
|
||
}
|
||
}
|
||
|
||
// Finalizer-Agent vorbereiten (falls konfiguriert)
|
||
FinalizerContext finalizerContext = null;
|
||
if (!string.IsNullOrEmpty(session.FinalizerAgentId))
|
||
{
|
||
var finalizerAgent = await _db.GetAgent(session.FinalizerAgentId);
|
||
if (finalizerAgent != null)
|
||
{
|
||
var finalizerModelId = ResolveModelId(finalizerAgent.Model);
|
||
var finalizerMarked = await _db.GetMarkedMessages(sessionId);
|
||
var finalizerMemDir = _memoryService.GetAgentMemoryDir(finalizerAgent.AgentId);
|
||
var finalizerSkills = await _db.GetAgentSkills(finalizerAgent.AgentId);
|
||
var finalizerWorkspace = workspace;
|
||
var finalizerSystemText = SystemPromptBuilder.Build(
|
||
finalizerAgent, session, finalizerMarked, _memoryService, finalizerMemDir,
|
||
finalizerSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
||
resolvedWorkspace: finalizerWorkspace);
|
||
|
||
var finalizerPrompt = new List<object>();
|
||
if (!string.IsNullOrWhiteSpace(finalizerSystemText))
|
||
finalizerPrompt.Add(new { type = "text", text = finalizerSystemText });
|
||
|
||
finalizerContext = new FinalizerContext
|
||
{
|
||
ModelId = finalizerModelId,
|
||
SystemPrompt = finalizerPrompt,
|
||
AgentId = finalizerAgent.AgentId,
|
||
Workspace = finalizerWorkspace,
|
||
UseMemory = finalizerAgent.UseMemory,
|
||
WebSearchVersion = finalizerAgent.WebSearchVersion,
|
||
ToolChoice = finalizerAgent.ToolChoice ?? "any",
|
||
Agent = finalizerAgent
|
||
};
|
||
}
|
||
}
|
||
|
||
return new ChatContext
|
||
{
|
||
ChainId = chainId,
|
||
SessionId = sessionId,
|
||
ModelId = modelId,
|
||
Workspace = workspace,
|
||
AgentId = agent.AgentId,
|
||
SystemPromptText = systemPromptText,
|
||
SystemPrompt = systemPrompt,
|
||
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>
|
||
public async Task<List<object>> BuildHistoryFromDb(string sessionId)
|
||
{
|
||
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 welche Chain als "vollständige letzte Chain" gilt.
|
||
// Wenn die letzte Chain leer oder nur tool_result enthält (z.B. nach ask_user_choice),
|
||
// dann muss die vorletzte Chain vollständig rekonstruiert werden.
|
||
int fullChainStartIndex = chainsList.Count - 1;
|
||
if (chainsList.Count >= 2)
|
||
{
|
||
var lastChainMsgs = (await _db.GetChainMessages(chainsList[chainsList.Count - 1].ChainId)).ToList();
|
||
if (lastChainMsgs.Count == 0 ||
|
||
(lastChainMsgs.All(m => m.Type == "tool_result") && lastChainMsgs.Count <= 2))
|
||
{
|
||
// Vorletzte Chain hatte einen ask_user_choice → beide Chains vollständig laden
|
||
fullChainStartIndex = chainsList.Count - 2;
|
||
}
|
||
}
|
||
|
||
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)) // Sub-Agent-Messages (preparer/review/finalizer) aus API-History ausfiltern
|
||
.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 == "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 = 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 = msg.Content ?? "", is_error = msg.IsError }
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return history;
|
||
}
|
||
}
|