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:
1 parent
2598656f1c
commit
5fe3f8ac87
12 files changed
+619
-18
No files matched your search
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user