- 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
610 lines
27 KiB
C#
610 lines
27 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 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
|
||
};
|
||
}
|
||
|
||
}
|