feat: Review-Agent (Multi-Agent-Kommunikation)

- Session hat optionalen ReviewAgentId + MaxReviewIterations
- Nach end_turn des Worker-Agents wird der Review-Agent aufgerufen
- Review-Agent bekommt Worker-Konversation als Kontext + kann Tools nutzen
- [APPROVED] in Review-Antwort → fertig, sonst Feedback an Worker
- Worker korrigiert und wird erneut reviewed (max N Runden)
- SSE-Events: review_start, review_text, review_tool_use, review_feedback, review_approved
- Chat-UI: Review-Phase als eigene Bubble mit Header, Text, Status
- Session-Page: Review-Agent-Picker (gleicher Agent-Card-Helper)
- Dashboard: Review-Agent-Badge auf Session-Cards
- DB-Migration: review_agent_id, max_review_iterations auf sessions
This commit is contained in:
Meleki committed 2026-03-06 16:04:00 +01:00
1 parent 2598656f1c
commit 5fe3f8ac87
12 files changed
+619 -18

No files matched your search

+352
View File
@@ -47,11 +47,13 @@ public class AnthropicService
string webSearchVersion = null,
int maxIterations = 100,
string toolChoice = "any",
ReviewContext review = null,
CancellationToken cancellationToken = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
int iterations = 0;
int reviewRound = 0;
string lastStopReason = "";
var chain = new ChainRecord
@@ -175,6 +177,31 @@ public class AnthropicService
if (stopReason != "tool_use")
{
// ── Review-Agent Phase ──
if (review != null && stopReason == "end_turn" && reviewRound < review.MaxReviewIterations)
{
var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, usage, iterations, reviewRound, cancellationToken);
iterations += reviewResult.ReviewIterationCount;
reviewRound++;
if (reviewResult.HasFeedback)
{
// Feedback als User-Message an Worker zurückgeben → nächste Iteration
messages.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = reviewResult.Feedback } }
});
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "user", Type = "text",
Content = reviewResult.Feedback
});
continue; // → nächste Worker-Iteration
}
}
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
await TryWriteSse(response, "done", new
@@ -242,6 +269,331 @@ public class AnthropicService
}
}
// ── Review-Agent Logik ──
private class ReviewResult
{
public bool HasFeedback { get; set; }
public string Feedback { get; set; }
public int ReviewIterationCount { get; set; }
}
/// <summary>
/// Führt den Review-Agent aus. Der Review-Agent bekommt die gesamte Konversation
/// und kann Tools nutzen (z.B. read_file um die Arbeit zu prüfen).
/// Antwortet er mit [APPROVED] → fertig. Sonst → Feedback wird an den Worker zurückgegeben.
/// reviewRound = aktuelle Runde (0-basiert), wird vom Caller hochgezählt.
/// </summary>
private async Task<ReviewResult> RunReview(
HttpResponse response,
ReviewContext review,
List<object> workerMessages,
string chainId,
string workspace,
string sessionId,
TokenUsage usage,
int baseIterations,
int reviewRound,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// SSE: Review-Phase Start
await TryWriteSse(response, "review_start", new
{
round = reviewRound + 1,
maxRounds = review.MaxReviewIterations,
agentId = review.AgentId,
agentName = review.Agent?.Name ?? "Review-Agent"
}, cancellationToken);
// Review-Agent bekommt eigene Message-History
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 result = new ReviewResult { ReviewIterationCount = reviewIterations };
// Prüfe ob [APPROVED]
if (!string.IsNullOrEmpty(reviewText) && reviewText.Contains("[APPROVED]"))
{
await TryWriteSse(response, "review_approved", new
{
round = reviewRound + 1,
agentName = review.Agent?.Name ?? "Review-Agent"
}, cancellationToken);
result.HasFeedback = false;
return result;
}
// Feedback – an Worker zurückspielen
result.HasFeedback = true;
result.Feedback = $"[Review-Feedback von {review.Agent?.Name ?? "Review-Agent"} (Runde {reviewRound + 1}/{review.MaxReviewIterations})]:\n{reviewText}";
await TryWriteSse(response, "review_feedback", new
{
round = reviewRound + 1,
maxRounds = review.MaxReviewIterations,
feedback = reviewText,
agentName = review.Agent?.Name ?? "Review-Agent"
}, cancellationToken);
return result;
}
/// <summary>
/// Baut die Messages für den Review-Agent auf.
/// Der Review-Agent bekommt die komplette Worker-Konversation als eine User-Message.
/// </summary>
private List<object> BuildReviewMessages(List<object> workerMessages)
{
// Worker-Konversation zusammenfassen
var summary = new StringBuilder();
summary.AppendLine("Hier ist die bisherige Konversation zwischen User und Worker-Agent. Bitte überprüfe ob die Aufgabe korrekt erledigt wurde:\n");
summary.AppendLine("---");
foreach (var msg in workerMessages)
{
var json = JsonSerializer.Serialize(msg);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (!el.TryGetProperty("role", out var role)) continue;
var roleStr = role.GetString() ?? "";
if (el.TryGetProperty("content", out var content))
{
if (content.ValueKind == JsonValueKind.String)
{
summary.AppendLine($"\n**{roleStr}:** {content.GetString()}");
}
else if (content.ValueKind == JsonValueKind.Array)
{
foreach (var block in content.EnumerateArray())
{
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
if (blockType == "text")
{
var text = block.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
summary.AppendLine($"\n**{roleStr}:** {text}");
}
else if (blockType == "tool_use")
{
var toolName = block.TryGetProperty("name", out var tn) ? tn.GetString() ?? "" : "";
var toolInput = block.TryGetProperty("input", out var ti) ? ti.ToString() : "{}";
summary.AppendLine($"\n**{roleStr} [Tool: {toolName}]:** {toolInput}");
}
else if (blockType == "tool_result")
{
var resultContent = block.TryGetProperty("content", out var rc) ? rc.ToString() : "";
// Tool-Results kürzen für Review-Kontext
if (resultContent.Length > 2000)
resultContent = resultContent[..2000] + "\n[... gekürzt]";
summary.AppendLine($"\n**{roleStr} [Tool-Result]:** {resultContent}");
}
}
}
}
}
summary.AppendLine("\n---");
summary.AppendLine("\nBitte überprüfe ob die Aufgabe korrekt erledigt wurde. Du kannst dazu auch Tools verwenden (z.B. Dateien lesen). Antworte mit exakt [APPROVED] wenn alles korrekt ist, oder beschreibe was falsch ist und korrigiert werden muss.");
return new List<object>
{
new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = summary.ToString() } }
}
};
}
/// <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.
/// </summary>
private async Task<(string text, int iterations)> RunReviewAgentChat(
HttpResponse response,
ReviewContext review,
List<object> messages,
string chainId,
string workspace,
string sessionId,
TokenUsage usage,
int baseIterations,
CancellationToken cancellationToken)
{
int iterations = 0;
var finalText = new StringBuilder();
int maxToolIterations = 20; // Review-Agent bekommt max 20 Tool-Iterationen
while (iterations < maxToolIterations)
{
cancellationToken.ThrowIfCancellationRequested();
iterations++;
var payload = new Dictionary<string, object>
{
["model"] = review.ModelId,
["max_tokens"] = 8192,
["messages"] = messages,
["stream"] = false
};
if (review.SystemPrompt != null && review.SystemPrompt.Count > 0)
payload["system"] = review.SystemPrompt;
var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = review.UseMemory && _memoryService != null && !string.IsNullOrEmpty(review.AgentId);
var hasTools = hasWorkspace || includeMemory;
var hasWebSearch = !string.IsNullOrEmpty(review.WebSearchVersion);
if (hasTools || hasWebSearch)
{
var tools = new List<object>();
if (hasTools)
tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory));
if (hasWebSearch)
tools.Add(new { type = review.WebSearchVersion, name = "web_search", max_uses = 5 });
payload["tools"] = tools;
var effectiveChoice = (review.ToolChoice == "any" && iterations == 1) ? "any" : "auto";
payload["tool_choice"] = new { type = effectiveChoice };
}
var client = CreateClient();
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
LogPayload(json, sessionId, chainId, baseIterations + iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
cancellationToken: cancellationToken);
var respBody = await httpResp.Content.ReadAsStringAsync(cancellationToken);
if (!httpResp.IsSuccessStatusCode)
{
return ($"Review-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();
if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64();
if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64();
if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64();
}
var stopReason = resp.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : "";
var content = resp.GetProperty("content");
var assistantContent = new List<object>();
var toolResults = new List<object>();
foreach (var block in content.EnumerateArray())
{
var blockType = block.GetProperty("type").GetString() ?? "";
if (blockType == "text")
{
var text = block.GetProperty("text").GetString() ?? "";
assistantContent.Add(new { type = "text", text });
finalText.Append(text);
// Review-Text per SSE an den Client streamen
await TryWriteSse(response, "review_text", new
{
text,
agentName = review.Agent?.Name ?? "Review-Agent",
agentId = review.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}"
});
}
else if (blockType == "tool_use")
{
var toolId = block.GetProperty("id").GetString() ?? "";
var toolName = block.GetProperty("name").GetString() ?? "";
var toolInput = block.GetProperty("input");
assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = toolInput });
// Tool-Use per SSE
await TryWriteSse(response, "review_tool_use", new
{
toolName,
toolId,
input = toolInput.ToString(),
agentName = review.Agent?.Name ?? "Review-Agent"
}, cancellationToken);
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = baseIterations + iterations,
Role = "assistant",
Type = "tool_use",
Content = toolInput.GetRawText(),
ToolName = toolName,
ToolCallId = toolId
});
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, workspace, _memoryService, review.AgentId, cancellationToken); }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
const int MaxToolResultChars = 20_000;
var truncatedResult = toolResult.Length > MaxToolResultChars
? toolResult[..MaxToolResultChars] + $"\n\n[... gekürzt, {toolResult.Length - MaxToolResultChars} weitere Zeichen ...]"
: toolResult;
toolResults.Add(new
{
type = "tool_result",
tool_use_id = toolId,
content = truncatedResult,
is_error = isError
});
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = baseIterations + iterations,
Role = "user",
Type = "tool_result",
Content = toolResult,
ToolName = toolName,
ToolCallId = toolId,
IsError = isError
});
}
}
messages.Add(new Dictionary<string, object> { ["role"] = "assistant", ["content"] = assistantContent });
if (stopReason == "end_turn" || stopReason == "stop_sequence" || toolResults.Count == 0)
break;
messages.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = toolResults });
}
return (finalText.ToString(), iterations);
}
/// <summary>
/// Non-Streaming Chat für Telegram. Gibt den finalen Antworttext zurück.
/// onIteration wird nach jeder Iteration aufgerufen (z.B. für Typing-Indicator).