Files
gott/Services/AnthropicService.Agents.cs
T
MelekiandClaude Opus 4.8 f21ed9ff9f Stream panel member responses live with a parallel SSE write-lock
Panel members now stream token-by-token and show tool uses in real time
(like the session agent) instead of delivering text only at completion.
The key constraint is byte-level safety: N members flushing SSE frames
concurrently on the shared HttpResponse would interleave and corrupt the
stream, so a SemaphoreSlim gate serializes the writes (not the model work).

- SseHelper.TryWriteSse takes an optional write-lock (default null, backward
  compatible)
- NonStreamingLoopCallbacks carries SseGate + MemberIndex; _text/_tool/_error
  events are tagged with the member index (review/finalizer stay at -1, no gate)
- CLI members forward TextDelta/ToolStart/ToolResult live instead of
  discarding everything but the final text
- Anthropic-API members stream per tool-round via the loop callbacks; OpenAI
  members fall back to collected text at panel_member_done
- Frontend demuxes by member index into per-bubble interleaved text/tool
  rendering

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:18:13 +02:00

836 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Models;
using Microsoft.AspNetCore.Http;
namespace Gott.Services;
public partial class AnthropicService
{
// ── Preparer-Agent Logik ──
public class PreparerResult
{
public bool ShouldForward { get; set; }
public string ForwardedPrompt { get; set; }
public string ResponseText { get; set; }
public int IterationCount { get; set; }
}
/// <summary>
/// Führt den Preparer-Agent aus. Der Preparer bekommt die User-Nachricht und die bisherige Konversation.
/// Antwortet er mit [FORWARD] → der Text nach [FORWARD] wird an den Session-Agent weitergegeben.
/// Sonst → seine Antwort wird als Rückfrage an den User gestreamt.
/// </summary>
public async Task<PreparerResult> RunPreparer(
HttpResponse response,
SubAgentContext preparer,
List<object> historyMessages,
string chainId,
string workspace,
string sessionId,
string apiToken,
CancellationToken cancellationToken)
{
// Preparer läuft VOR dem Worker und nutzt den Request-CancellationToken direkt.
// Bei Client-Disconnect wird der Preparer abgebrochen (kein Sinn weiterzumachen,
// da der Worker noch nicht gestartet hat).
cancellationToken.ThrowIfCancellationRequested();
await SseHelper.TryWriteSse(response, "preparer_start", new
{
agentId = preparer.AgentId,
agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent"
}, cancellationToken);
// Preparer bekommt die History als Kontext
var preparerMessages = new List<object>(historyMessages);
var usage = new TokenUsage();
string text;
int iterations;
if (!string.IsNullOrEmpty(preparer.Provider) && _openAiService != null)
{
// OpenAI-kompatibler Preparer
var subCtx = BuildSubAgentChatContext(preparer, chainId, sessionId, apiToken);
text = await _openAiService.ChatNonStreaming(subCtx, preparerMessages);
iterations = 1;
}
else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable)
{
// Kein API-Key → CLI-Fallback
(text, iterations) = await RunSubAgentViaCli(
response, preparer, preparerMessages, chainId, sessionId,
"preparer", cancellationToken, cancellationToken);
}
else
{
(text, iterations) = await RunAgentChat(
response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId,
preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion,
preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage,
0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent",
cancellationToken, cancellationToken, preparer.Effort);
}
var result = new PreparerResult { IterationCount = iterations, ResponseText = text };
// Logik: Standardmäßig wird IMMER weitergeleitet.
// Nur wenn der Preparer explizit [QUESTION] nutzt, wird gestoppt (= Rückfrage an User).
// [FORWARD] wird weiterhin erkannt und entfernt, ist aber nicht mehr zwingend nötig.
if (!string.IsNullOrEmpty(text) && text.Contains("[QUESTION]"))
{
// Explizite Rückfrage an User
var questionText = text.Replace("[QUESTION]", "").Trim();
result.ShouldForward = false;
result.ResponseText = questionText;
await SseHelper.TryWriteSse(response, "preparer_done", new
{
agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent",
forwarded = false,
responseText = questionText
}, cancellationToken);
}
else
{
// Weiterleitung (Standard): [FORWARD]-Tag entfernen falls vorhanden
var forwardedText = text ?? "";
if (forwardedText.Contains("[FORWARD]"))
{
var idx = forwardedText.IndexOf("[FORWARD]");
forwardedText = forwardedText.Substring(idx + "[FORWARD]".Length).Trim();
}
result.ShouldForward = true;
result.ForwardedPrompt = forwardedText;
await SseHelper.TryWriteSse(response, "preparer_done", new
{
agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent",
forwarded = true
}, cancellationToken);
}
return result;
}
// ── 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 Text-Konversation
/// und kann Tools nutzen (z.B. read_file um die Arbeit zu prüfen).
/// Default = Approved. Nur bei explizitem [FEEDBACK]-Tag wird Feedback an den Worker zurückgegeben.
/// reviewRound = aktuelle Runde (0-basiert), wird vom Caller hochgezählt.
/// </summary>
private async Task<ReviewResult> RunReview(
HttpResponse response,
SubAgentContext review,
List<object> workerMessages,
string chainId,
string workspace,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
int reviewRound,
CancellationToken workCt,
CancellationToken sseCt)
{
await SseHelper.TryWriteSse(response, "review_start", new
{
agentId = review.AgentId,
agentName = review.Agent?.Name ?? "Review-Agent",
round = reviewRound
}, sseCt);
// Review-Agent bekommt die Worker-Konversation als Kontext
var reviewMessages = BuildReviewMessages(workerMessages);
string text;
int iterations;
if (!string.IsNullOrEmpty(review.Provider) && _openAiService != null)
{
// OpenAI-kompatibler Reviewer
var subCtx = BuildSubAgentChatContext(review, chainId, sessionId, apiToken);
text = await _openAiService.ChatNonStreaming(subCtx, reviewMessages);
iterations = 1;
}
else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable)
{
// Kein API-Key → CLI-Fallback
(text, iterations) = await RunSubAgentViaCli(
response, review, reviewMessages, chainId, sessionId,
"review", workCt, sseCt);
}
else
{
(text, iterations) = await RunAgentChat(
response, review.ModelId, review.SystemPrompt, review.AgentId,
review.Workspace, review.UseMemory, review.WebSearchVersion,
review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage,
baseIterations, "review", review.Agent?.Name ?? "Review-Agent",
workCt, sseCt, review.Effort);
}
var result = new ReviewResult { ReviewIterationCount = iterations };
if (!string.IsNullOrEmpty(text) && text.Contains("[FEEDBACK]"))
{
var feedbackText = text.Substring(text.IndexOf("[FEEDBACK]") + "[FEEDBACK]".Length).Trim();
result.HasFeedback = true;
result.Feedback = $"[Review-Feedback, Runde {reviewRound + 1}]: {feedbackText}";
}
await SseHelper.TryWriteSse(response, "review_done", new
{
agentName = review.Agent?.Name ?? "Review-Agent",
round = reviewRound,
hasFeedback = result.HasFeedback
}, sseCt);
return result;
}
/// <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,
SubAgentContext finalizer,
List<object> workerMessages,
string chainId,
string workspace,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
CancellationToken workCt,
CancellationToken sseCt)
{
await SseHelper.TryWriteSse(response, "finalizer_start", new
{
agentId = finalizer.AgentId,
agentName = finalizer.Agent?.Name ?? "Finalizer-Agent"
}, sseCt);
// Finalizer bekommt die Konversation als Kontext (gleiche Methode wie Review)
var finalizerMessages = BuildReviewMessages(workerMessages);
string text;
int iterations;
if (!string.IsNullOrEmpty(finalizer.Provider) && _openAiService != null)
{
var subCtx = BuildSubAgentChatContext(finalizer, chainId, sessionId, apiToken);
text = await _openAiService.ChatNonStreaming(subCtx, finalizerMessages);
iterations = 1;
}
else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable)
{
// Kein API-Key → CLI-Fallback
(text, iterations) = await RunSubAgentViaCli(
response, finalizer, finalizerMessages, chainId, sessionId,
"finalizer", workCt, sseCt);
}
else
{
(text, iterations) = await RunAgentChat(
response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId,
finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion,
finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage,
baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent",
workCt, sseCt, finalizer.Effort);
}
await SseHelper.TryWriteSse(response, "finalizer_done", new
{
agentName = finalizer.Agent?.Name ?? "Finalizer-Agent"
}, sseCt);
return iterations;
}
/// <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)
{
// Nur die Text-Konversation extrahieren – Tool-Calls und Tool-Results weglassen.
// Der Review/Finalizer-Agent hat eigene Tools und kann bei Bedarf selbst Dateien lesen.
// Aufeinanderfolgende Messages gleicher Rolle werden zusammengeführt,
// da die Anthropic API abwechselnde user/assistant-Rollen erfordert.
var messages = new List<object>();
string lastRole = null;
var currentParts = new List<string>();
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)) continue;
string textContent = null;
if (content.ValueKind == JsonValueKind.String)
{
textContent = content.GetString();
}
else if (content.ValueKind == JsonValueKind.Array)
{
var parts = new List<string>();
foreach (var block in content.EnumerateArray())
{
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
if (blockType == "text" && block.TryGetProperty("text", out var txt))
parts.Add(txt.GetString() ?? "");
}
if (parts.Count > 0)
textContent = string.Join("\n", parts);
}
if (string.IsNullOrEmpty(textContent)) continue;
if (roleStr == lastRole)
{
// Gleiche Rolle → Text anhängen
currentParts.Add(textContent);
}
else
{
// Rolle wechselt → vorherige Message abschließen
if (lastRole != null && currentParts.Count > 0)
{
messages.Add(new
{
role = lastRole,
content = string.Join("\n\n", currentParts)
});
}
lastRole = roleStr;
currentParts = new List<string> { textContent };
}
}
// Letzte Message abschließen
if (lastRole != null && currentParts.Count > 0)
{
messages.Add(new
{
role = lastRole,
content = string.Join("\n\n", currentParts)
});
}
// Sicherstellen, dass die Messages mit "user" beginnen
if (messages.Count > 0)
{
var firstJson = JsonSerializer.Serialize(messages[0]);
var firstEl = JsonSerializer.Deserialize<JsonElement>(firstJson);
if (firstEl.GetProperty("role").GetString() != "user")
{
messages.Insert(0, new { role = "user", content = "Bitte überprüfe die folgende Konversation und die Arbeit des Agents." });
}
}
else
{
messages.Add(new { role = "user", content = "Es gibt keine Konversation zum Überprüfen." });
}
return messages;
}
/// <summary>
/// Baut einen ChatContext für Sub-Agents (Preparer/Review/Finalizer) auf,
/// wenn diese einen OpenAI-kompatiblen Provider nutzen.
/// </summary>
private ChatContext BuildSubAgentChatContext(SubAgentContext sub, string chainId, string sessionId, string apiToken)
{
// SystemPrompt als Text extrahieren (SubAgent hat List<object> mit { type, text } Blöcken)
var systemText = "";
if (sub.SystemPrompt != null)
{
var parts = new List<string>();
foreach (var block in sub.SystemPrompt)
{
var json = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (el.TryGetProperty("text", out var t))
parts.Add(t.GetString() ?? "");
}
systemText = string.Join("\n\n", parts);
}
return new ChatContext
{
ChainId = chainId,
SessionId = sessionId,
ModelId = sub.ModelId,
Provider = sub.Provider,
Workspace = sub.Workspace,
AgentId = sub.AgentId,
SystemPromptText = systemText,
UseMemory = sub.UseMemory,
WebSearchVersion = sub.WebSearchVersion,
MaxIterations = 20,
ApiToken = apiToken
};
}
// ── Shared Non-Streaming Tool Loop ──
/// <summary>
/// Optionale Callbacks für RunNonStreamingToolLoop.
/// </summary>
private class NonStreamingLoopCallbacks
{
/// <summary>Wird zu Beginn jeder Iteration aufgerufen (z.B. Typing-Indicator).</summary>
public Func<int, Task> OnIteration { get; set; }
/// <summary>SSE-Response für Client-Events. Null = keine SSE-Events.</summary>
public HttpResponse SseResponse { get; set; }
public CancellationToken SseCt { get; set; }
/// <summary>SSE-Event-Prefix (z.B. "review" → "review_text", "review_tool").</summary>
public string SsePrefix { get; set; }
public string AgentName { get; set; }
/// <summary>Optionaler Write-Lock: serialisiert SSE-Writes, wenn mehrere Agenten
/// (Panel-Member) parallel auf DIESELBE HttpResponse streamen. Null = kein Lock.</summary>
public SemaphoreSlim SseGate { get; set; }
/// <summary>Member-Index für Panel-Events (wird in die SSE-Payloads gelegt,
/// damit das Frontend Token/Tool-Events der richtigen Member-Bubble zuordnet).
/// -1 = kein Panel-Member (Review/Finalizer ignorieren das Feld).</summary>
public int MemberIndex { get; set; } = -1;
/// <summary>Tag für DB-Records (z.B. "review", "finalizer").</summary>
public string Tag { get; set; }
/// <summary>Ob Text-Embeddings in die Queue geschrieben werden.</summary>
public bool EnqueueEmbeddings { get; set; }
/// <summary>Ob Messages in die DB persistiert werden. false = Panel-Member:
/// die interne Arbeit (Text/Tool-Calls) wird nicht gespeichert, der Caller
/// persistiert nur die finale Proposal-Message selbst (mit [Panel: Name]-Prefix).</summary>
public bool Persist { get; set; } = true;
/// <summary>
/// Optionaler Tool-Executor. Parameter: (toolName, input, ct) → result.
/// Bei Fehler Exception werfen. Wenn null, wird Standard-Executor verwendet.
/// </summary>
public Func<string, JsonElement, CancellationToken, Task<string>> ExecuteTool { get; set; }
}
/// <summary>
/// Gemeinsamer Non-Streaming Tool-Loop für RunAgentChat und ChatNonStreaming.
/// Baut Payload, sendet API-Request, verarbeitet Content-Blöcke, führt Tools aus.
/// systemPrompt kann List&lt;object&gt; (Agent) oder string (ChatContext) sein.
/// </summary>
private async Task<(string text, int iterations)> RunNonStreamingToolLoop(
string modelId,
object systemPrompt,
ToolPayloadOptions toolOpts,
List<object> messages,
string chainId,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIteration,
int maxIterations,
CancellationToken ct,
NonStreamingLoopCallbacks cb = null,
string effort = null)
{
cb ??= new NonStreamingLoopCallbacks();
int iterations = 0;
var finalText = new StringBuilder();
while (iterations < maxIterations)
{
ct.ThrowIfCancellationRequested();
iterations++;
if (cb.OnIteration != null) await cb.OnIteration(iterations);
var payload = new Dictionary<string, object>
{
["model"] = modelId,
["max_tokens"] = 16384,
["messages"] = messages
};
// Reasoning-Effort (Opus 4.5+, Sonnet 4.6+). "" / "high" / null = Default weglassen.
if (!string.IsNullOrEmpty(effort) && effort != "high")
payload["output_config"] = new { effort };
if (systemPrompt is List<object> spBlocks && spBlocks.Count > 0)
{
// Cache-Breakpoint auf den letzten System-Prompt-Block (1h TTL).
ApplyCacheControlToLast(spBlocks);
payload["system"] = spBlocks;
}
else if (systemPrompt is string spText && !string.IsNullOrEmpty(spText))
{
// String-System-Prompt → in Block-Form mit cache_control konvertieren.
payload["system"] = new List<object>
{
new Dictionary<string, object>
{
["type"] = "text",
["text"] = spText,
["cache_control"] = new { type = "ephemeral", ttl = "1h" }
}
};
}
AddToolsToPayload(payload, toolOpts with { Iteration = iterations });
var client = CreateClient(apiToken);
var json = JsonSerializer.Serialize(payload);
LogPayload(json, sessionId, chainId, baseIteration + iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
using var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
HttpCompletionOption.ResponseHeadersRead,
ct
);
TryUpdateKeyMeta(httpResp, apiToken);
if (!httpResp.IsSuccessStatusCode)
{
var errBody = await httpResp.Content.ReadAsStringAsync(ct);
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
if (cb.Persist)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true, Tag = cb.Tag
});
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { index = cb.MemberIndex, error = errMsg }, cb.SseCt, cb.SseGate);
break;
}
var respJson = await httpResp.Content.ReadAsStringAsync(ct);
var respDoc = JsonSerializer.Deserialize<JsonElement>(respJson);
if (respDoc.TryGetProperty("usage", out var u))
TrackUsage(usage, u);
var stopReason = respDoc.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : "";
var contentArr = respDoc.TryGetProperty("content", out var c) ? c : default;
if (contentArr.ValueKind != JsonValueKind.Array) break;
var assistantContent = new List<object>();
var toolResults = new List<object>();
foreach (var block in contentArr.EnumerateArray())
{
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
if (blockType == "text")
{
var text = block.GetProperty("text").GetString() ?? "";
finalText.Append(text);
assistantContent.Add(new { type = "text", text });
if (cb.Persist)
{
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "assistant", Type = "text",
Content = text, Tag = cb.Tag
});
if (cb.EnqueueEmbeddings)
_embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text);
}
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { index = cb.MemberIndex, text, agentName = cb.AgentName }, cb.SseCt, cb.SseGate);
}
else if (blockType == "tool_use")
{
var toolId = block.GetProperty("id").GetString() ?? "";
var toolName = block.GetProperty("name").GetString() ?? "";
JsonElement inputEl;
try { inputEl = block.GetProperty("input"); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = inputEl });
if (cb.Persist)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "assistant", Type = "tool_use",
ToolName = toolName, ToolCallId = toolId,
Content = inputEl.ToString(), Tag = cb.Tag
});
ct.ThrowIfCancellationRequested();
string toolResult;
bool isError = false;
try
{
toolResult = cb.ExecuteTool != null
? await cb.ExecuteTool(toolName, inputEl, ct)
: await _toolService.ExecuteTool(toolName, inputEl, new ToolExecutionContext
{
Workspace = toolOpts.Workspace, MemoryService = _memoryService,
AgentId = toolOpts.AgentId, CancellationToken = ct,
VectorSearch = _vectorSearch, EmbeddingBg = _embeddingBg
});
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
ct.ThrowIfCancellationRequested();
toolResults.Add(new
{
type = "tool_result",
tool_use_id = toolId,
content = TruncateToolResult(toolResult),
is_error = isError
});
if (cb.Persist)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "user", Type = "tool_result",
ToolCallId = toolId,
Content = toolResult, IsError = isError, Tag = cb.Tag
});
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { index = cb.MemberIndex, name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt, cb.SseGate);
}
}
messages.Add(new { role = "assistant", content = assistantContent });
if (stopReason != "tool_use" || toolResults.Count == 0)
break;
messages.Add(new { role = "user", content = toolResults });
}
return (finalText.ToString(), iterations);
}
/// <summary>
/// Agent-Chat (non-streaming API, SSE-Events für Client).
/// Wrapper um RunNonStreamingToolLoop für Review-/Finalizer-Agents.
/// </summary>
private Task<(string text, int iterations)> RunAgentChat(
HttpResponse response,
string modelId,
List<object> systemPrompt,
string agentId,
string agentWorkspace,
bool useMemory,
string webSearchVersion,
string agentToolChoice,
List<object> messages,
string chainId,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
string agentType,
string agentName,
CancellationToken workCt,
CancellationToken sseCt,
string effort = null)
{
return RunNonStreamingToolLoop(
modelId, systemPrompt,
new ToolPayloadOptions
{
Workspace = agentWorkspace, AgentId = agentId,
UseMemory = useMemory, WebSearchVersion = webSearchVersion,
ToolChoice = agentToolChoice
},
messages, chainId, sessionId, apiToken, usage,
baseIterations, 20, workCt,
new NonStreamingLoopCallbacks
{
SseResponse = response, SseCt = sseCt,
SsePrefix = agentType, AgentName = agentName,
Tag = agentType
},
effort);
}
/// <summary>
/// Führt einen Sub-Agent (Preparer/Review/Finalizer) über die Claude CLI aus.
/// Sammelt den gesamten Text und gibt ihn zurück. Wird als Fallback genutzt,
/// wenn kein API-Key vorhanden ist.
/// </summary>
private async Task<(string text, int iterations)> RunSubAgentViaCli(
HttpResponse response,
SubAgentContext sub,
List<object> historyMessages,
string chainId,
string sessionId,
string agentType,
CancellationToken workCt,
CancellationToken sseCt)
{
if (_cliService == null || !_cliService.IsAvailable)
return ("", 0);
// System-Prompt als Text extrahieren
var systemText = new StringBuilder();
if (sub.SystemPrompt != null)
{
foreach (var block in sub.SystemPrompt)
{
var json = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (el.TryGetProperty("text", out var t))
systemText.AppendLine(t.GetString());
}
}
// Letzte User-Message als Prompt extrahieren
var prompt = ExtractLastUserText(historyMessages) ?? "";
// Konversations-History als Kontext
var historyContext = BuildHistoryContext(historyMessages);
if (!string.IsNullOrEmpty(historyContext))
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
var fullText = new StringBuilder();
var result = await _cliService.ChatStreamingAsync(
prompt,
async evt =>
{
switch (evt.Type)
{
case CliEventType.TextDelta:
fullText.Append(evt.Text);
await SseHelper.TryWriteSse(response, $"{agentType}_text",
new { text = evt.Text, agentName = sub.Agent?.Name ?? agentType }, sseCt);
break;
case CliEventType.ToolResult:
await SseHelper.TryWriteSse(response, $"{agentType}_tool",
new { name = evt.ToolName, result = evt.ToolResultContent, isError = evt.ToolResultIsError, agentName = sub.Agent?.Name ?? agentType }, sseCt);
break;
}
},
modelId: sub.ModelId,
systemPrompt: systemText.ToString(),
workspace: sub.Workspace,
hasWorkspace: !string.IsNullOrEmpty(sub.Workspace),
effort: sub.Effort,
cancellationToken: workCt
);
// Text in DB speichern
if (fullText.Length > 0)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 0,
Role = "assistant",
Type = "text",
Content = fullText.ToString(),
Tag = agentType
});
}
return (fullText.ToString(), 1);
}
/// <summary>
/// Non-Streaming Chat für Telegram. Gibt den finalen Antworttext zurück.
/// Wrapper um RunNonStreamingToolLoop mit Chain-Management und Embedding-Enqueue.
/// onIteration wird nach jeder Iteration aufgerufen (z.B. für Typing-Indicator).
/// </summary>
public async Task<string> ChatNonStreaming(
ChatContext ctx,
List<object> messages,
Func<int, Task> onIteration = null)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
var chain = new ChainRecord
{
ChainId = ctx.ChainId,
SessionId = ctx.SessionId,
Model = ctx.ModelId
};
try
{
var (text, iterations) = await RunNonStreamingToolLoop(
ctx.ModelId, ctx.SystemPromptText,
new ToolPayloadOptions
{
Workspace = ctx.Workspace, AgentId = ctx.AgentId,
UseMemory = ctx.UseMemory, WebSearchVersion = ctx.WebSearchVersion,
ToolChoice = "auto"
},
messages, ctx.ChainId, ctx.SessionId, ctx.ApiToken, usage,
0, ctx.MaxIterations, CancellationToken.None,
new NonStreamingLoopCallbacks
{
OnIteration = onIteration,
EnqueueEmbeddings = true,
ExecuteTool = (name, input, ct) =>
_toolService.ExecuteTool(name, input, new ToolExecutionContext
{
Workspace = ctx.Workspace, MemoryService = _memoryService, AgentId = ctx.AgentId,
CancellationToken = ct, VectorSearch = _vectorSearch, EmbeddingBg = _embeddingBg,
Db = _db, ChatService = _chatService, AnthropicService = this, OpenAiService = _openAiService
})
},
ctx.Effort);
var finalStopReason = text.Length > 0 ? "end_turn" : "error";
await FinalizeChain(chain, usage, iterations, finalStopReason, sw);
return text;
}
catch (Exception ex)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = 0,
Role = "system", Type = "error",
Content = $"Fehler: {ex.Message}", IsError = true
});
await FinalizeChain(chain, usage, 0, "error", sw);
return "";
}
}
}