Der Tag-Filter in BuildHistoryFromDb filterte alle Messages mit Tag aus, sofern der Tag nicht dem angeforderten includeTag entsprach. openclaw-import ist jedoch kein Sub-Agent-Tag (wie preparer/review/ finalizer), sondern ein Herkunfts-Tag und muss immer in der History enthalten sein.
478 lines
21 KiB
C#
478 lines
21 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 Vorbereiter-Mechanismus
|
||
preparerPrompt.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?
|
||
```" });
|
||
|
||
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>
|
||
/// <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)
|
||
{
|
||
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) || 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 == "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;
|
||
}
|
||
}
|