Three related panel-member improvements:
Tool-box streaming fix: correlate tool boxes by tool ID instead of a
single pointer (fixes orphaned spinners and empty boxes when a member
calls multiple tools in one message), forward CLI ToolEnd to capture the
tool input (command/path), normalize tool names, clean up orphaned
spinners on finalize, and add a blinking cursor during text streaming.
Image/screenshot support: CLI panel members now extract image refs from
the last user message (like the worker) and drop them as temp files
readable via the Read tool, so members can see screenshots and file
attachments instead of being blind on image-based questions.
Reload persistence: each member persists a structured JSON transcript
({name, model, segments:[text|tool]}) instead of plain text, so a reload
restores text AND tool boxes interleaved exactly like live. history.js
renders the transcript and keeps back-compat for the old [Panel: Name]
plaintext format.
- AnthropicService.Agents.cs: NonStreamingLoopCallbacks.TranscriptSink,
loop collects text/tool segments, tool input added to combined event
- AnthropicService.Panel.cs: CLI member ID-correlation + ToolEnd input +
image refs + transcript; RunPanelMember persists one JSON transcript
- stream-ui.js: tool boxes keyed by ID, name normalization, cursor,
orphaned-spinner cleanup
- history.js: render JSON transcript with tool boxes, plaintext fallback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
852 lines
34 KiB
C#
852 lines
34 KiB
C#
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>
|
||
/// Optionaler Transkript-Sammler (Panel-Member). Wenn gesetzt, sammelt der Loop
|
||
/// die interleavte Abfolge aus Text- und Tool-Segmenten, damit der Caller sie als
|
||
/// EINE strukturierte Message persistieren kann (Reload-Wiederherstellung der
|
||
/// Tool-Boxen). Segmente: {t:"text", v:...} bzw. {t:"tool", name, input, result, error}.
|
||
/// </summary>
|
||
public List<object> TranscriptSink { 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<object> (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);
|
||
|
||
cb.TranscriptSink?.Add(new Dictionary<string, object> { ["t"] = "text", ["v"] = text });
|
||
}
|
||
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, input = inputEl.ToString(), result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt, cb.SseGate);
|
||
|
||
cb.TranscriptSink?.Add(new Dictionary<string, object>
|
||
{
|
||
["t"] = "tool", ["name"] = toolName, ["input"] = inputEl.ToString(),
|
||
["result"] = TruncateToolResult(toolResult), ["error"] = isError
|
||
});
|
||
}
|
||
}
|
||
|
||
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 "";
|
||
}
|
||
}
|
||
}
|