Anthropic hat Anfang März 2026 die Default-Cache-TTL stillschweigend von 1h auf 5min gesenkt. Bei Sessions mit Pausen >5min wird der Cache jedes Mal neu geschrieben (teuer). Dieser Commit aktiviert die extended 1h-TTL über beide Pfade. API-Pfad (AnthropicService): - Beta-Header "extended-cache-ttl-2025-04-11" hinzugefügt - Falsches Top-Level cache_control (Root-Level wurde ignoriert) entfernt - Neue Helper ApplyCacheControlToLast() setzt cache_control idempotent auf das letzte Element via JSON-Roundtrip zu Dictionary - 2 Cache-Breakpoints pro Iteration: * Letztes Tool (cached gesamten Tool-Block) * Letzter System-Prompt-Block (cached System-Prompt) - Sub-Agent-Loop (Preparer/Review/Finalizer): String-System-Prompt wird bei Bedarf in Block-Form mit cache_control konvertiert CLI-Pfad (ClaudeCliService): - Env-Var ENABLE_PROMPT_CACHING_1H=1 für jeden CLI-Child-Prozess gesetzt (offizieller Opt-in seit Claude CLI 2.1.108, gilt für API-Key, Bedrock, Vertex, Foundry)
807 lines
31 KiB
C#
807 lines
31 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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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>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>
|
||
/// 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<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)
|
||
{
|
||
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
|
||
};
|
||
|
||
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}";
|
||
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 { error = errMsg }, cb.SseCt);
|
||
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 });
|
||
|
||
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 { text, agentName = cb.AgentName }, cb.SseCt);
|
||
}
|
||
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 });
|
||
|
||
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
|
||
});
|
||
|
||
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 { name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt);
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
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
|
||
});
|
||
}
|
||
|
||
/// <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),
|
||
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
|
||
})
|
||
});
|
||
|
||
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 "";
|
||
}
|
||
}
|
||
}
|