feat: Finalizer-Agent – dritter Agent der nach Worker + Review einschreitet

Backend:
- SessionRecord + DB-Migration: finalizer_agent_id auf sessions
- ChatContext.FinalizerContext (Model, SystemPrompt, Tools, Workspace)
- ChatService: FinalizerContext aufbauen (analog zu ReviewContext)
- AnthropicService: RunFinalizer() nach Review-Phase (oder direkt nach Worker)
- RunReviewAgentChat() refactored zu generischem RunAgentChat()
  mit agentType-Parameter ('review' | 'finalizer') für SSE-Events und DB-Tags
- SSE-Events: finalizer_start, finalizer_text, finalizer_tool_use, finalizer_done

Frontend:
- Session-Page: Finalizer-Agent-Picker (im 'Weitere Einstellungen' Bereich)
- Chat: SSE-Events für Finalizer-Phase verarbeiten
- UI: addReviewBubble/finalizeReviewBubble unterstützen agentType 'finalizer'
  mit eigener Farbe (success-green) und Flag-Checkered-Icon
- renderMarkdown: [Finalizer: AgentName] Tags parsen
- History: Finalizer-Messages als .finalizer-bubble rendern
- Shared-Page: Finalizer-Messages korrekt erkennen und stylen
- Dashboard: Finalizer-Badge (flag-checkered) auf Session-Cards
This commit is contained in:
Meleki committed 2026-03-06 23:35:30 +01:00
1 parent a3f2aa38c1
commit 4bec740765
14 files changed
+228 -46

No files matched your search

+86 -29
View File
@@ -48,6 +48,7 @@ public class AnthropicService
int maxIterations = 100,
string toolChoice = "any",
ReviewContext review = null,
FinalizerContext finalizer = null,
CancellationToken cancellationToken = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
@@ -202,6 +203,13 @@ public class AnthropicService
}
}
// ── Finalizer-Agent Phase ──
if (finalizer != null && stopReason == "end_turn")
{
var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, usage, iterations, cancellationToken);
iterations += finalizerIterations;
}
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
await TryWriteSse(response, "done", new
@@ -311,9 +319,12 @@ public class AnthropicService
var reviewMessages = BuildReviewMessages(workerMessages);
// Review-Agent iteriert mit eigenen Tools
var (reviewText, reviewIterations) = await RunReviewAgentChat(
response, review, reviewMessages, chainId, workspace, sessionId,
usage, baseIterations, cancellationToken);
var (reviewText, reviewIterations) = await RunAgentChat(
response, review.ModelId, review.SystemPrompt, review.AgentId,
workspace, review.UseMemory, review.WebSearchVersion,
review.ToolChoice, reviewMessages, chainId, sessionId, usage,
baseIterations, "review", review.Agent?.Name ?? "Review-Agent",
cancellationToken);
var result = new ReviewResult { ReviewIterationCount = reviewIterations };
@@ -344,6 +355,47 @@ public class AnthropicService
return result;
}
// ── Finalizer-Agent Logik ──
/// <summary>
/// Führt den Finalizer-Agent aus. Wird einmal nach der Review-Phase (oder direkt nach dem Worker) aufgerufen.
/// Der Finalizer kann Tools nutzen, gibt aber kein Feedback an den Worker zurück.
/// </summary>
private async Task<int> RunFinalizer(
HttpResponse response,
FinalizerContext finalizer,
List<object> workerMessages,
string chainId,
string workspace,
string sessionId,
TokenUsage usage,
int baseIterations,
CancellationToken cancellationToken)
{
await TryWriteSse(response, "finalizer_start", new
{
agentId = finalizer.AgentId,
agentName = finalizer.Agent?.Name ?? "Finalizer-Agent"
}, cancellationToken);
// Finalizer bekommt die Konversation als Kontext (gleiche Methode wie Review)
var finalizerMessages = BuildReviewMessages(workerMessages);
var (text, iterations) = await RunAgentChat(
response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId,
finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion,
finalizer.ToolChoice, finalizerMessages, chainId, sessionId, usage,
baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent",
cancellationToken);
await TryWriteSse(response, "finalizer_done", new
{
agentName = finalizer.Agent?.Name ?? "Finalizer-Agent"
}, cancellationToken);
return iterations;
}
/// <summary>
/// Baut die Messages für den Review-Agent auf.
/// Der Review-Agent bekommt die komplette Worker-Konversation als eine User-Message.
@@ -411,23 +463,32 @@ public class AnthropicService
}
/// <summary>
/// Führt den Review-Agent Chat aus (non-streaming für den Agent, aber SSE-Events für den Client).
/// Gibt den finalen Text und die Anzahl der Iterationen zurück.
/// Generischer Agent-Chat (non-streaming für den Agent, aber SSE-Events für den Client).
/// Wird für Review-Agent und Finalizer-Agent verwendet.
/// agentType steuert SSE-Event-Prefixe und DB-Message-Tags ("review" oder "finalizer").
/// </summary>
private async Task<(string text, int iterations)> RunReviewAgentChat(
private async Task<(string text, int iterations)> RunAgentChat(
HttpResponse response,
ReviewContext review,
string modelId,
List<object> systemPrompt,
string agentId,
string agentWorkspace,
bool useMemory,
string webSearchVersion,
string agentToolChoice,
List<object> messages,
string chainId,
string workspace,
string sessionId,
TokenUsage usage,
int baseIterations,
string agentType,
string agentName,
CancellationToken cancellationToken)
{
int iterations = 0;
var finalText = new StringBuilder();
int maxToolIterations = 20; // Review-Agent bekommt max 20 Tool-Iterationen
int maxToolIterations = 20;
var tag = agentType == "finalizer" ? "Finalizer" : "Review";
while (iterations < maxToolIterations)
{
@@ -436,19 +497,19 @@ public class AnthropicService
var payload = new Dictionary<string, object>
{
["model"] = review.ModelId,
["model"] = modelId,
["max_tokens"] = 8192,
["messages"] = messages,
["stream"] = false
};
if (review.SystemPrompt != null && review.SystemPrompt.Count > 0)
payload["system"] = review.SystemPrompt;
if (systemPrompt != null && systemPrompt.Count > 0)
payload["system"] = systemPrompt;
var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = review.UseMemory && _memoryService != null && !string.IsNullOrEmpty(review.AgentId);
var hasWorkspace = !string.IsNullOrEmpty(agentWorkspace);
var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId);
var hasTools = hasWorkspace || includeMemory;
var hasWebSearch = !string.IsNullOrEmpty(review.WebSearchVersion);
var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion);
if (hasTools || hasWebSearch)
{
@@ -456,9 +517,9 @@ public class AnthropicService
if (hasTools)
tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory));
if (hasWebSearch)
tools.Add(new { type = review.WebSearchVersion, name = "web_search", max_uses = 5 });
tools.Add(new { type = webSearchVersion, name = "web_search", max_uses = 5 });
payload["tools"] = tools;
var effectiveChoice = (review.ToolChoice == "any" && iterations == 1) ? "any" : "auto";
var effectiveChoice = (agentToolChoice == "any" && iterations == 1) ? "any" : "auto";
payload["tool_choice"] = new { type = effectiveChoice };
}
@@ -475,12 +536,11 @@ public class AnthropicService
if (!httpResp.IsSuccessStatusCode)
{
return ($"Review-Agent API Fehler: {(int)httpResp.StatusCode}: {respBody}", iterations);
return ($"{tag}-Agent API Fehler: {(int)httpResp.StatusCode}: {respBody}", iterations);
}
var resp = JsonSerializer.Deserialize<JsonElement>(respBody);
// Usage tracken (auf den gemeinsamen Zähler)
if (resp.TryGetProperty("usage", out var u))
{
if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64();
@@ -505,22 +565,20 @@ public class AnthropicService
assistantContent.Add(new { type = "text", text });
finalText.Append(text);
// Review-Text per SSE an den Client streamen
await TryWriteSse(response, "review_text", new
await TryWriteSse(response, $"{agentType}_text", new
{
text,
agentName = review.Agent?.Name ?? "Review-Agent",
agentId = review.AgentId
agentName,
agentId
}, cancellationToken);
// In DB speichern (als Review-Message markiert)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = baseIterations + iterations,
Role = "assistant",
Type = "text",
Content = $"[Review: {review.Agent?.Name ?? "Review-Agent"}] {text}"
Content = $"[{tag}: {agentName}] {text}"
});
}
else if (blockType == "tool_use")
@@ -531,13 +589,12 @@ public class AnthropicService
assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = toolInput });
// Tool-Use per SSE
await TryWriteSse(response, "review_tool_use", new
await TryWriteSse(response, $"{agentType}_tool_use", new
{
toolName,
toolId,
input = toolInput.ToString(),
agentName = review.Agent?.Name ?? "Review-Agent"
agentName
}, cancellationToken);
await _db.SaveMessage(new MessageRecord
@@ -553,7 +610,7 @@ public class AnthropicService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, workspace, _memoryService, review.AgentId, cancellationToken); }
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, cancellationToken); }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
const int MaxToolResultChars = 20_000;