- Python MCP-Server (stdio, JSON-RPC 2.0) für remote_execute via Claude CLI - Interner API-Endpoint /api/internal/remote-execute mit Token-Auth - MCP-Config wird automatisch generiert wenn Remote-Verbindung vorhanden - Auth-Middleware: /api/internal/ von Auth ausgenommen - Frontend: :::ASK_USER_CHOICE::: Blöcke als interaktive Buttons rendern - Streaming: Unvollständige Choice-Blöcke werden während des Streams versteckt - tool-ui.js: MCP-Tool-Namen generisch normalisieren (mcp__server__tool → tool) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2403 lines
106 KiB
C#
2403 lines
106 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.IO.Compression;
|
||
using System.Linq;
|
||
using System.Net.Http;
|
||
using System.Net.Http.Headers;
|
||
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 class AnthropicService
|
||
{
|
||
private readonly IHttpClientFactory _httpFactory;
|
||
private readonly ToolService _toolService;
|
||
private readonly DatabaseService _db;
|
||
private readonly MemoryService _memoryService;
|
||
private readonly ServerSettingsService _settings;
|
||
private readonly VectorSearchService _vectorSearch;
|
||
private readonly EmbeddingBackgroundService _embeddingBg;
|
||
private ChatService _chatService;
|
||
private TelegramService _telegramService;
|
||
private ClaudeCliService _cliService;
|
||
|
||
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
|
||
{
|
||
_httpFactory = httpFactory;
|
||
_toolService = toolService;
|
||
_db = db;
|
||
_memoryService = memoryService;
|
||
_settings = settings;
|
||
_vectorSearch = vectorSearch;
|
||
_embeddingBg = embeddingBg;
|
||
}
|
||
|
||
public void SetCliService(ClaudeCliService cliService)
|
||
{
|
||
_cliService = cliService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prüft ob die Claude CLI verfügbar ist.
|
||
/// </summary>
|
||
public bool IsCliAvailable => _cliService?.IsAvailable ?? false;
|
||
|
||
/// <summary>
|
||
/// Wird nach der Konstruktion gesetzt um zirkuläre Abhängigkeit zu vermeiden
|
||
/// (ChatService → AnthropicService → ChatService).
|
||
/// </summary>
|
||
public void SetChatService(ChatService chatService)
|
||
{
|
||
_chatService = chatService;
|
||
}
|
||
|
||
public void SetTelegramService(TelegramService telegramService)
|
||
{
|
||
_telegramService = telegramService;
|
||
}
|
||
|
||
private OpenAiService _openAiService;
|
||
public void SetOpenAiService(OpenAiService openAiService)
|
||
{
|
||
_openAiService = openAiService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
|
||
/// Fire-and-forget – Fehler werden stillschweigend ignoriert.
|
||
/// </summary>
|
||
private void TryUpdateKeyMeta(HttpResponseMessage httpResp, string apiToken)
|
||
{
|
||
try
|
||
{
|
||
var meta = AnthropicKeyChecker.ExtractMetaFromHeaders(httpResp.Headers);
|
||
if (meta == null || string.IsNullOrEmpty(apiToken)) return;
|
||
_ = _db.UpdateApiKeyMetaByToken(apiToken, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h);
|
||
}
|
||
catch { /* silent */ }
|
||
}
|
||
|
||
public async Task StreamChat(
|
||
HttpResponse response,
|
||
string modelId,
|
||
List<object> systemPrompt,
|
||
List<object> messages,
|
||
string chainId,
|
||
string workspace,
|
||
string apiToken,
|
||
string sessionId = null,
|
||
string agentId = null,
|
||
string userId = null,
|
||
bool useMemory = true,
|
||
string webSearchVersion = null,
|
||
int maxIterations = 100,
|
||
string toolChoice = "any",
|
||
SubAgentContext review = null,
|
||
SubAgentContext finalizer = null,
|
||
bool allowUserChoice = true,
|
||
bool planMode = false,
|
||
bool useAutoContext = true,
|
||
bool useGitHubSearch = false,
|
||
bool useSerperSearch = false,
|
||
CancellationToken workCt = default,
|
||
CancellationToken hardCt = default,
|
||
CancellationToken sseCt = default,
|
||
RemoteConnectionManager remoteMgr = null)
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var usage = new TokenUsage();
|
||
int iterations = 0;
|
||
int reviewRound = 0;
|
||
string lastStopReason = "";
|
||
int promptTrimRetries = 0;
|
||
|
||
var chain = new ChainRecord
|
||
{
|
||
ChainId = chainId,
|
||
SessionId = sessionId,
|
||
Model = modelId
|
||
};
|
||
|
||
try
|
||
{
|
||
// ── Automatische Kontext-Injektion via Embedding-Suche ──
|
||
if (useAutoContext && _vectorSearch != null && systemPrompt != null && messages.Count > 0)
|
||
{
|
||
try
|
||
{
|
||
// Letzte User-Message als Query extrahieren
|
||
var lastUserText = ExtractLastUserText(messages);
|
||
if (!string.IsNullOrWhiteSpace(lastUserText))
|
||
{
|
||
var contextResults = await _vectorSearch.SearchAsync(lastUserText, agentId, topN: 5, minScore: 0.45f);
|
||
// Nur Ergebnisse aus anderen Sessions (nicht aktuelle Chain) und nicht zu kurze
|
||
contextResults.RemoveAll(r => r.ChainId == chainId || (r.TextPreview?.Length ?? 0) < 20);
|
||
// Memory-Dateien durchsuchen
|
||
var memoryResults = await _vectorSearch.SearchMemoryFilesAsync(lastUserText, agentId, topN: 3, minScore: 0.45f);
|
||
|
||
if (contextResults.Count > 0 || memoryResults.Count > 0)
|
||
{
|
||
var ctxSb = new StringBuilder();
|
||
ctxSb.AppendLine("\n<recalled_context>");
|
||
ctxSb.AppendLine("Die folgende Information wurde automatisch abgerufen und könnte relevant sein:");
|
||
foreach (var r in contextResults)
|
||
{
|
||
var date = DateTime.TryParse(r.CreatedAt, out var dt) ? dt.ToString("yyyy-MM-dd HH:mm") : r.CreatedAt;
|
||
ctxSb.AppendLine($"- [Gespräch, {r.Role}, {date}]: {r.TextPreview}");
|
||
}
|
||
foreach (var r in memoryResults)
|
||
{
|
||
ctxSb.AppendLine($"- [Memory, {r.FilePath}]: {r.TextPreview}");
|
||
}
|
||
ctxSb.AppendLine("</recalled_context>");
|
||
systemPrompt.Add(new { type = "text", text = ctxSb.ToString() });
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"[AutoContext] Error: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
while (iterations < maxIterations)
|
||
{
|
||
// Hard-Stop: Sofortiger Abbruch aus while-Loop (Doppel-Klick Stop)
|
||
hardCt.ThrowIfCancellationRequested();
|
||
workCt.ThrowIfCancellationRequested();
|
||
iterations++;
|
||
|
||
var payload = new Dictionary<string, object>
|
||
{
|
||
["model"] = modelId,
|
||
["max_tokens"] = 16384,
|
||
["cache_control"] = new { type = "ephemeral" },
|
||
["messages"] = messages,
|
||
["stream"] = true
|
||
};
|
||
|
||
if (systemPrompt != null && systemPrompt.Count > 0)
|
||
payload["system"] = systemPrompt;
|
||
|
||
var hasRemote = remoteMgr?.GetBySessionId(sessionId) != null;
|
||
AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode, includeRemote: hasRemote, includeGitHubSearch: useGitHubSearch && _toolService.HasGitHubToken, includeSerperSearch: useSerperSearch && _toolService.HasSerperKey);
|
||
|
||
var client = CreateClient(apiToken);
|
||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||
LogPayload(json, sessionId, chainId, 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,
|
||
workCt
|
||
);
|
||
|
||
TryUpdateKeyMeta(httpResp, apiToken);
|
||
|
||
if (!httpResp.IsSuccessStatusCode)
|
||
{
|
||
var errBody = await httpResp.Content.ReadAsStringAsync(workCt);
|
||
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
|
||
|
||
// Auto-Retry bei "prompt too long" / "exceed context limit": History kürzen und erneut versuchen
|
||
if ((int)httpResp.StatusCode == 400 &&
|
||
(errBody.Contains("prompt is too long") || errBody.Contains("exceed context limit")) &&
|
||
messages.Count > 4 && promptTrimRetries < 5)
|
||
{
|
||
promptTrimRetries++;
|
||
int trimCount = Math.Max(2, messages.Count / 5); // 20% der Messages entfernen
|
||
trimCount = TrimMessagesCleanly(messages, trimCount);
|
||
if (trimCount > 0)
|
||
{
|
||
await SseHelper.TryWriteSse(response, "info", new { message = $"History zu lang – {trimCount} ältere Messages entfernt, Retry ({promptTrimRetries}/5)..." }, sseCt);
|
||
iterations--; // Iteration nicht zählen, war ja kein echter Versuch
|
||
continue; // → nächste while-Iteration mit kürzerer History
|
||
}
|
||
}
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = iterations,
|
||
Role = "system", Type = "error",
|
||
Content = errMsg, IsError = true
|
||
});
|
||
await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt);
|
||
await FinalizeChain(chain, usage, iterations, "error", sw);
|
||
await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
|
||
return;
|
||
}
|
||
|
||
var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId, apiToken);
|
||
lastStopReason = stopReason;
|
||
promptTrimRetries = 0; // Erfolgreicher API-Call → Retry-Counter resetten
|
||
|
||
messages.Add(new { role = "assistant", content = assistantContent });
|
||
|
||
// Assistant-Blöcke persistieren
|
||
foreach (var block in assistantContent)
|
||
{
|
||
var blockJson = JsonSerializer.Serialize(block);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(blockJson);
|
||
var blockType = el.GetProperty("type").GetString() ?? "";
|
||
|
||
if (blockType == "text")
|
||
{
|
||
var textContent = el.GetProperty("text").GetString();
|
||
var msgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = iterations,
|
||
Role = "assistant", Type = "text",
|
||
Content = textContent
|
||
});
|
||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textContent ?? "");
|
||
}
|
||
else if (blockType == "tool_use")
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = iterations,
|
||
Role = "assistant", Type = "tool_use",
|
||
ToolName = el.GetProperty("name").GetString(),
|
||
ToolCallId = el.GetProperty("id").GetString(),
|
||
Content = el.GetProperty("input").ToString()
|
||
});
|
||
}
|
||
else if (blockType == "server_tool_use")
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = iterations,
|
||
Role = "assistant", Type = "server_tool_use",
|
||
Content = el.GetRawText()
|
||
});
|
||
}
|
||
else if (blockType == "web_search_tool_result")
|
||
{
|
||
// Raw-JSON speichern für History-Rebuild (API braucht den vollständigen Block)
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = iterations,
|
||
Role = "assistant", Type = "web_search_result",
|
||
Content = el.GetRawText()
|
||
});
|
||
}
|
||
else if (blockType.EndsWith("_tool_result"))
|
||
{
|
||
// Generische Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = iterations,
|
||
Role = "assistant", Type = "server_tool_result",
|
||
Content = el.GetRawText()
|
||
});
|
||
}
|
||
}
|
||
|
||
// Tool Results persistieren
|
||
foreach (var tr in executedTools)
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = iterations,
|
||
Role = "user", Type = "tool_result",
|
||
ToolCallId = tr.Id,
|
||
Content = tr.Result,
|
||
IsError = tr.IsError
|
||
});
|
||
}
|
||
|
||
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, apiToken, usage, iterations, reviewRound, workCt, sseCt);
|
||
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
|
||
}
|
||
}
|
||
|
||
// ── Finalizer-Agent Phase ──
|
||
if (finalizer != null && stopReason == "end_turn")
|
||
{
|
||
var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, apiToken, usage, iterations, workCt, sseCt);
|
||
iterations += finalizerIterations;
|
||
}
|
||
|
||
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
|
||
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
inputTokens = usage.InputTokens,
|
||
outputTokens = usage.OutputTokens,
|
||
cacheCreationTokens = usage.CacheCreationTokens,
|
||
cacheReadTokens = usage.CacheReadTokens,
|
||
iterations,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason = finalStopReason,
|
||
streamErrorInfo,
|
||
planMode
|
||
}, sseCt);
|
||
return;
|
||
}
|
||
|
||
var toolResults = new List<object>();
|
||
foreach (var tr in executedTools)
|
||
{
|
||
toolResults.Add(new
|
||
{
|
||
type = "tool_result",
|
||
tool_use_id = tr.Id,
|
||
content = TruncateToolResult(tr.Result),
|
||
is_error = tr.IsError
|
||
});
|
||
}
|
||
|
||
messages.Add(new { role = "user", content = toolResults });
|
||
|
||
// Hard-Stop Check nach Tool-Ausführung: Sofort raus statt nächste Iteration
|
||
hardCt.ThrowIfCancellationRequested();
|
||
}
|
||
|
||
await FinalizeChain(chain, usage, iterations, "max_iterations", sw);
|
||
await SseHelper.TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt);
|
||
await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
|
||
}
|
||
catch (OperationCanceledException) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
|
||
{
|
||
// Stop-Button gedrückt (workCt oder hardCt wurde gecancelled)
|
||
await FinalizeChain(chain, usage, iterations, "stopped", sw);
|
||
// sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None
|
||
// Der SSE-Stream zum Client ist noch offen – nur der Stop-Button hat ausgelöst
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
inputTokens = usage.InputTokens,
|
||
outputTokens = usage.OutputTokens,
|
||
cacheCreationTokens = usage.CacheCreationTokens,
|
||
cacheReadTokens = usage.CacheReadTokens,
|
||
iterations,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason = "stopped",
|
||
planMode
|
||
}, CancellationToken.None);
|
||
}
|
||
catch (Exception) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
|
||
{
|
||
// Stream-Dispose durch CancellationToken-Registration löst
|
||
// ObjectDisposedException/IOException aus statt OperationCanceledException.
|
||
// Wenn workCt/hardCt gecancelled ist, ist das ein regulärer Stop.
|
||
await FinalizeChain(chain, usage, iterations, "stopped", sw);
|
||
// sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
inputTokens = usage.InputTokens,
|
||
outputTokens = usage.OutputTokens,
|
||
cacheCreationTokens = usage.CacheCreationTokens,
|
||
cacheReadTokens = usage.CacheReadTokens,
|
||
iterations,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason = "stopped",
|
||
planMode
|
||
}, CancellationToken.None);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Client-Disconnect (cancellationToken gecancelled, aber NICHT workCt/hardCt)
|
||
// Agent-Arbeit ist leider schon abgebrochen – Chain als "client_disconnected" finalisieren
|
||
// (sollte durch TryWriteSse eigentlich nicht mehr passieren)
|
||
await FinalizeChain(chain, usage, iterations, "client_disconnected", sw);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Unerwarteter Fehler – in DB speichern und versuchen an Client zu senden
|
||
var errMsg = $"Stream-Fehler: {ex.Message}";
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = iterations,
|
||
Role = "system", Type = "error",
|
||
Content = errMsg, IsError = true
|
||
});
|
||
await FinalizeChain(chain, usage, iterations, "error", sw);
|
||
await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt);
|
||
await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
|
||
}
|
||
}
|
||
|
||
// ── 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
|
||
{
|
||
(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)
|
||
{
|
||
workCt.ThrowIfCancellationRequested();
|
||
|
||
// SSE: Review-Phase Start
|
||
await SseHelper.TryWriteSse(response, "review_start", new
|
||
{
|
||
round = reviewRound + 1,
|
||
maxRounds = review.MaxReviewIterations,
|
||
agentId = review.AgentId,
|
||
agentName = review.Agent?.Name ?? "Review-Agent"
|
||
}, sseCt);
|
||
|
||
// Review-Agent bekommt eigene Message-History
|
||
var reviewMessages = BuildReviewMessages(workerMessages);
|
||
|
||
string reviewText;
|
||
int reviewIterations;
|
||
|
||
if (!string.IsNullOrEmpty(review.Provider) && _openAiService != null)
|
||
{
|
||
var subCtx = BuildSubAgentChatContext(review, chainId, sessionId, apiToken);
|
||
reviewText = await _openAiService.ChatNonStreaming(subCtx, reviewMessages);
|
||
reviewIterations = 1;
|
||
}
|
||
else
|
||
{
|
||
(reviewText, reviewIterations) = await RunAgentChat(
|
||
response, review.ModelId, review.SystemPrompt, review.AgentId,
|
||
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 = reviewIterations };
|
||
|
||
// Default = Approved (analog zum Preparer, wo Default = Forward ist).
|
||
// Nur bei explizitem [FEEDBACK]-Tag wird Feedback an den Worker zurückgespielt.
|
||
// [APPROVED] wird weiterhin erkannt, ist aber nicht mehr zwingend nötig.
|
||
if (!string.IsNullOrEmpty(reviewText) && reviewText.Contains("[FEEDBACK]"))
|
||
{
|
||
// Explizites Feedback – an Worker zurückspielen
|
||
var feedbackText = reviewText.Replace("[FEEDBACK]", "").Replace("[APPROVED]", "").Trim();
|
||
result.HasFeedback = true;
|
||
result.Feedback = $"[Review-Feedback von {review.Agent?.Name ?? "Review-Agent"} (Runde {reviewRound + 1}/{review.MaxReviewIterations})]:\n{feedbackText}";
|
||
|
||
await SseHelper.TryWriteSse(response, "review_feedback", new
|
||
{
|
||
round = reviewRound + 1,
|
||
maxRounds = review.MaxReviewIterations,
|
||
feedback = feedbackText,
|
||
agentName = review.Agent?.Name ?? "Review-Agent"
|
||
}, sseCt);
|
||
}
|
||
else
|
||
{
|
||
// Default: Approved (kein [FEEDBACK]-Tag → alles OK)
|
||
await SseHelper.TryWriteSse(response, "review_approved", new
|
||
{
|
||
round = reviewRound + 1,
|
||
agentName = review.Agent?.Name ?? "Review-Agent"
|
||
}, sseCt);
|
||
result.HasFeedback = false;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ── Finalizer-Agent Logik ──
|
||
|
||
/// <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
|
||
{
|
||
(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() ?? "";
|
||
|
||
var textParts = new List<string>();
|
||
|
||
if (el.TryGetProperty("content", out var content))
|
||
{
|
||
if (content.ValueKind == JsonValueKind.String)
|
||
{
|
||
var s = content.GetString() ?? "";
|
||
if (!string.IsNullOrWhiteSpace(s))
|
||
textParts.Add(s);
|
||
}
|
||
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() ?? "" : "";
|
||
if (!string.IsNullOrWhiteSpace(text))
|
||
textParts.Add(text);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (textParts.Count == 0) continue;
|
||
|
||
if (roleStr == lastRole)
|
||
{
|
||
// Gleiche Rolle wie vorher → zusammenführen
|
||
currentParts.AddRange(textParts);
|
||
}
|
||
else
|
||
{
|
||
// Neue Rolle → vorherige Message abschließen
|
||
if (lastRole != null && currentParts.Count > 0)
|
||
{
|
||
messages.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = lastRole,
|
||
["content"] = new List<object> { new { type = "text", text = string.Join("\n\n", currentParts) } }
|
||
});
|
||
}
|
||
lastRole = roleStr;
|
||
currentParts = new List<string>(textParts);
|
||
}
|
||
}
|
||
|
||
// Letzte angesammelte Message abschließen
|
||
if (lastRole != null && currentParts.Count > 0)
|
||
{
|
||
messages.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = lastRole,
|
||
["content"] = new List<object> { new { type = "text", text = string.Join("\n\n", currentParts) } }
|
||
});
|
||
}
|
||
|
||
// Anthropic erfordert: erste Message = user, letzte Message = user,
|
||
// und abwechselnde Rollen (durch die Zusammenführung oben bereits sichergestellt).
|
||
if (messages.Count > 0)
|
||
{
|
||
var first = (Dictionary<string, object>)messages[0];
|
||
if ((string)first["role"] == "assistant")
|
||
{
|
||
messages.Insert(0, new Dictionary<string, object>
|
||
{
|
||
["role"] = "user",
|
||
["content"] = new List<object> { new { type = "text", text = "Hier ist die bisherige Konversation. Bitte überprüfe den aktuellen Zustand." } }
|
||
});
|
||
}
|
||
|
||
var last = (Dictionary<string, object>)messages[messages.Count - 1];
|
||
if ((string)last["role"] == "assistant")
|
||
{
|
||
messages.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "user",
|
||
["content"] = new List<object> { new { type = "text", text = "Bitte überprüfe ob die Aufgabe korrekt erledigt wurde." } }
|
||
});
|
||
}
|
||
}
|
||
|
||
// Falls keine Text-Messages vorhanden, Fallback mit Hinweis
|
||
if (messages.Count == 0)
|
||
{
|
||
messages.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "user",
|
||
["content"] = new List<object> { new { type = "text", text = "Die bisherige Konversation enthielt nur Tool-Aufrufe ohne Text. Bitte überprüfe den aktuellen Zustand des Workspaces mit deinen Tools." } }
|
||
});
|
||
}
|
||
|
||
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
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generischer Agent-Chat (non-streaming für den Agent, aber SSE-Events für den Client).
|
||
/// Wird für Review-Agent und Finalizer-Agent verwendet.
|
||
/// agentType steuert SSE-Event-Prefixe und DB-Message-Tags ("review" oder "finalizer").
|
||
/// </summary>
|
||
private async 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 = default)
|
||
{
|
||
int iterations = 0;
|
||
var finalText = new StringBuilder();
|
||
int maxToolIterations = 20;
|
||
var tag = agentType == "preparer" ? "Preparer" : agentType == "finalizer" ? "Finalizer" : "Review";
|
||
|
||
while (iterations < maxToolIterations)
|
||
{
|
||
workCt.ThrowIfCancellationRequested();
|
||
iterations++;
|
||
|
||
var payload = new Dictionary<string, object>
|
||
{
|
||
["model"] = modelId,
|
||
["max_tokens"] = 8192,
|
||
["cache_control"] = new { type = "ephemeral" },
|
||
["messages"] = messages,
|
||
["stream"] = false
|
||
};
|
||
|
||
if (systemPrompt != null && systemPrompt.Count > 0)
|
||
payload["system"] = systemPrompt;
|
||
|
||
AddToolsToPayload(payload, agentWorkspace, agentId, useMemory, webSearchVersion, agentToolChoice, iterations);
|
||
|
||
var client = CreateClient(apiToken);
|
||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||
LogPayload(json, sessionId, chainId, baseIterations + 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 },
|
||
cancellationToken: workCt);
|
||
|
||
TryUpdateKeyMeta(httpResp, apiToken);
|
||
|
||
var respBody = await httpResp.Content.ReadAsStringAsync(workCt);
|
||
|
||
if (!httpResp.IsSuccessStatusCode)
|
||
{
|
||
// Auto-Retry bei "prompt too long" in Sub-Agents
|
||
if ((int)httpResp.StatusCode == 400 &&
|
||
(respBody.Contains("prompt is too long") || respBody.Contains("exceed context limit")) &&
|
||
messages.Count > 4)
|
||
{
|
||
int trimCount = Math.Max(2, messages.Count / 5);
|
||
trimCount = TrimMessagesCleanly(messages, trimCount);
|
||
if (trimCount > 0)
|
||
{
|
||
await SseHelper.TryWriteSse(response, "info", new { message = $"{tag}: History zu lang – {trimCount} Messages entfernt, Retry..." }, sseCt);
|
||
iterations--;
|
||
continue;
|
||
}
|
||
}
|
||
return ($"{tag}-Agent API Fehler: {(int)httpResp.StatusCode}: {respBody}", iterations);
|
||
}
|
||
|
||
var resp = JsonSerializer.Deserialize<JsonElement>(respBody);
|
||
|
||
if (resp.TryGetProperty("usage", out var u))
|
||
TrackUsage(usage, u);
|
||
|
||
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);
|
||
|
||
await SseHelper.TryWriteSse(response, $"{agentType}_text", new
|
||
{
|
||
text,
|
||
agentName,
|
||
agentId
|
||
}, sseCt);
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = baseIterations + iterations,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = $"[{tag}: {agentName}] {text}",
|
||
Tag = agentType
|
||
});
|
||
}
|
||
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 });
|
||
|
||
await SseHelper.TryWriteSse(response, $"{agentType}_tool_use", new
|
||
{
|
||
toolName,
|
||
toolId,
|
||
input = toolInput.ToString(),
|
||
agentName
|
||
}, sseCt);
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = baseIterations + iterations,
|
||
Role = "assistant",
|
||
Type = "tool_use",
|
||
Content = toolInput.GetRawText(),
|
||
ToolName = toolName,
|
||
ToolCallId = toolId,
|
||
Tag = agentType
|
||
});
|
||
|
||
string toolResult;
|
||
bool isError = false;
|
||
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, telegramService: _telegramService, apiToken: apiToken); }
|
||
catch (OperationCanceledException) { throw; }
|
||
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
||
|
||
toolResults.Add(new
|
||
{
|
||
type = "tool_result",
|
||
tool_use_id = toolId,
|
||
content = TruncateToolResult(toolResult),
|
||
is_error = isError
|
||
});
|
||
|
||
await SseHelper.TryWriteSse(response, $"{agentType}_tool_end", new
|
||
{
|
||
toolName,
|
||
toolId,
|
||
input = toolInput.ToString(),
|
||
result = toolResult,
|
||
isError,
|
||
agentName
|
||
}, sseCt);
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = baseIterations + iterations,
|
||
Role = "user",
|
||
Type = "tool_result",
|
||
Content = toolResult,
|
||
ToolName = toolName,
|
||
ToolCallId = toolId,
|
||
IsError = isError,
|
||
Tag = agentType
|
||
});
|
||
}
|
||
else if (blockType == "web_search_tool_result")
|
||
{
|
||
assistantContent.Add(JsonSerializer.Deserialize<JsonElement>(block.GetRawText()));
|
||
// Raw-JSON speichern für History-Rebuild (API braucht den vollständigen Block)
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = baseIterations + iterations,
|
||
Role = "assistant",
|
||
Type = "web_search_result",
|
||
Content = block.GetRawText(),
|
||
Tag = agentType
|
||
});
|
||
}
|
||
else if (blockType == "server_tool_use")
|
||
{
|
||
var (toolId, toolName, inputEl) = ParseServerToolUse(block);
|
||
assistantContent.Add(new { type = "server_tool_use", id = toolId, name = toolName, input = inputEl });
|
||
}
|
||
else if (blockType.EndsWith("_tool_result"))
|
||
{
|
||
// Generische Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
|
||
assistantContent.Add(JsonSerializer.Deserialize<JsonElement>(block.GetRawText()));
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = baseIterations + iterations,
|
||
Role = "assistant", Type = "server_tool_result",
|
||
Content = block.GetRawText(),
|
||
Tag = agentType
|
||
});
|
||
}
|
||
}
|
||
|
||
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).
|
||
/// </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();
|
||
int iterations = 0;
|
||
var finalText = new StringBuilder();
|
||
|
||
var chain = new ChainRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
SessionId = ctx.SessionId,
|
||
Model = ctx.ModelId
|
||
};
|
||
|
||
try
|
||
{
|
||
while (iterations < ctx.MaxIterations)
|
||
{
|
||
iterations++;
|
||
onIteration?.Invoke(iterations);
|
||
|
||
var payload = new Dictionary<string, object>
|
||
{
|
||
["model"] = ctx.ModelId,
|
||
["max_tokens"] = 16384,
|
||
["cache_control"] = new { type = "ephemeral" },
|
||
["messages"] = messages,
|
||
["stream"] = false
|
||
};
|
||
|
||
if (ctx.SystemPrompt != null && ctx.SystemPrompt.Count > 0)
|
||
payload["system"] = ctx.SystemPrompt;
|
||
|
||
AddToolsToPayload(payload, ctx.Workspace, ctx.AgentId, ctx.UseMemory, ctx.WebSearchVersion, ctx.ToolChoice, iterations);
|
||
|
||
var client = CreateClient(ctx.ApiToken);
|
||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||
LogPayload(json, ctx.SessionId, ctx.ChainId, 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 });
|
||
|
||
TryUpdateKeyMeta(httpResp, ctx.ApiToken);
|
||
|
||
var respBody = await httpResp.Content.ReadAsStringAsync();
|
||
|
||
if (!httpResp.IsSuccessStatusCode)
|
||
{
|
||
// Auto-Retry bei "prompt too long" in Non-Streaming
|
||
if ((int)httpResp.StatusCode == 400 &&
|
||
(respBody.Contains("prompt is too long") || respBody.Contains("exceed context limit")) &&
|
||
messages.Count > 4)
|
||
{
|
||
int trimCount = Math.Max(2, messages.Count / 5);
|
||
trimCount = TrimMessagesCleanly(messages, trimCount);
|
||
if (trimCount > 0)
|
||
{
|
||
iterations--;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
var errMsg = $"API {(int)httpResp.StatusCode}: {respBody}";
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = iterations,
|
||
Role = "system", Type = "error",
|
||
Content = errMsg, IsError = true
|
||
});
|
||
await FinalizeChain(chain, usage, iterations, "error", sw);
|
||
throw new Exception($"Claude API Fehler: {(int)httpResp.StatusCode}");
|
||
}
|
||
|
||
var resp = JsonSerializer.Deserialize<JsonElement>(respBody);
|
||
|
||
// Usage tracken
|
||
if (resp.TryGetProperty("usage", out var u))
|
||
TrackUsage(usage, u);
|
||
|
||
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);
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = iterations,
|
||
Role = "assistant", Type = "text",
|
||
Content = 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 });
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = 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, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, userId: ctx.Session?.UserId, telegramService: _telegramService, apiToken: ctx.ApiToken); }
|
||
catch (OperationCanceledException) { throw; }
|
||
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
||
|
||
toolResults.Add(new
|
||
{
|
||
type = "tool_result",
|
||
tool_use_id = toolId,
|
||
content = TruncateToolResult(toolResult),
|
||
is_error = isError
|
||
});
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = iterations,
|
||
Role = "user", Type = "tool_result",
|
||
Content = toolResult,
|
||
ToolName = toolName, ToolCallId = toolId,
|
||
IsError = isError
|
||
});
|
||
}
|
||
else if (blockType == "web_search_tool_result")
|
||
{
|
||
assistantContent.Add(JsonSerializer.Deserialize<JsonElement>(block.GetRawText()));
|
||
// Raw-JSON speichern für History-Rebuild (API braucht den vollständigen Block)
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = iterations,
|
||
Role = "assistant", Type = "web_search_result",
|
||
Content = block.GetRawText()
|
||
});
|
||
}
|
||
else if (blockType == "server_tool_use")
|
||
{
|
||
var (toolId, toolName, inputEl) = ParseServerToolUse(block);
|
||
assistantContent.Add(new { type = "server_tool_use", id = toolId, name = toolName, input = inputEl });
|
||
}
|
||
else if (blockType.EndsWith("_tool_result"))
|
||
{
|
||
// Generische Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
|
||
assistantContent.Add(JsonSerializer.Deserialize<JsonElement>(block.GetRawText()));
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = iterations,
|
||
Role = "assistant", Type = "server_tool_result",
|
||
Content = block.GetRawText()
|
||
});
|
||
}
|
||
}
|
||
|
||
messages.Add(new Dictionary<string, object> { ["role"] = "assistant", ["content"] = assistantContent });
|
||
|
||
if (stopReason == "end_turn" || stopReason == "stop_sequence" || toolResults.Count == 0)
|
||
{
|
||
await FinalizeChain(chain, usage, iterations, stopReason, sw);
|
||
break;
|
||
}
|
||
|
||
messages.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = toolResults });
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
if (iterations > 0)
|
||
await FinalizeChain(chain, usage, iterations, "error", sw);
|
||
throw;
|
||
}
|
||
|
||
return finalText.Length > 0 ? finalText.ToString() : "(Keine Textantwort)";
|
||
}
|
||
|
||
private void LogPayload(string json, string sessionId, string chainId, int iteration)
|
||
{
|
||
if (_settings == null || !_settings.GetBool("PayloadLogging", false)) return;
|
||
try
|
||
{
|
||
var logsDir = Path.Combine(AppContext.BaseDirectory, "data", "logs");
|
||
Directory.CreateDirectory(logsDir);
|
||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
var baseName = $"{timestamp}-{sessionId}-{chainId}-iter{iteration}-payload";
|
||
|
||
if (_settings?.GetBool("PayloadLoggingGzip", false) ?? false)
|
||
{
|
||
var filePath = Path.Combine(logsDir, baseName + ".json.gz");
|
||
using var fs = File.Create(filePath);
|
||
using var gz = new GZipStream(fs, CompressionLevel.Fastest);
|
||
using var writer = new StreamWriter(gz, Encoding.UTF8);
|
||
writer.Write(json);
|
||
}
|
||
else
|
||
{
|
||
File.WriteAllText(Path.Combine(logsDir, baseName + ".json"), json);
|
||
}
|
||
}
|
||
catch { /* Logging darf nie die App crashen */ }
|
||
}
|
||
|
||
private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw, string streamErrorInfo = null)
|
||
=> SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo);
|
||
|
||
private async Task<(string StopReason, List<object> Content, List<ToolResult> Tools, string StreamErrorInfo)> ProcessStream(
|
||
HttpResponse response,
|
||
HttpResponseMessage httpResp,
|
||
TokenUsage usage,
|
||
string workspace,
|
||
string agentId = null,
|
||
CancellationToken workCt = default,
|
||
CancellationToken sseCt = default,
|
||
RemoteConnectionManager remoteMgr = null,
|
||
string sessionId = null,
|
||
string userId = null,
|
||
string apiToken = null)
|
||
{
|
||
var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
|
||
// Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht.
|
||
// StreamReader.ReadLineAsync propagiert den CancellationToken nicht zuverlässig
|
||
// an den underlying NetworkStream – der Read blockiert sonst bis Anthropic
|
||
// das nächste Chunk sendet (kann bei extended thinking Minuten dauern).
|
||
using var ctReg = workCt.Register(() => { try { stream.Dispose(); } catch { } });
|
||
using var reader = new StreamReader(stream);
|
||
|
||
var stopReason = "";
|
||
var assistantContent = new List<object>();
|
||
var currentToolId = "";
|
||
var currentToolName = "";
|
||
var currentToolInputJson = new StringBuilder();
|
||
var currentTextBlock = new StringBuilder();
|
||
|
||
var blockTypes = new Dictionary<int, string>();
|
||
var webSearchResultBlocks = new Dictionary<int, object>();
|
||
var webSearchSources = new Dictionary<int, List<object>>();
|
||
var serverToolResultBlocks = new Dictionary<int, object>(); // bash_code_execution_tool_result, code_execution_tool_result etc.
|
||
var executedTools = new List<ToolResult>();
|
||
var streamErrors = new List<object>();
|
||
|
||
string line;
|
||
while ((line = await reader.ReadLineAsync(workCt)) != null)
|
||
{
|
||
if (!line.StartsWith("data: ")) continue;
|
||
var data = line["data: ".Length..];
|
||
if (string.IsNullOrWhiteSpace(data)) continue;
|
||
|
||
JsonElement evt;
|
||
try { evt = JsonSerializer.Deserialize<JsonElement>(data); }
|
||
catch { continue; }
|
||
|
||
var evtType = evt.TryGetProperty("type", out var t) ? t.GetString() ?? "" : "";
|
||
|
||
switch (evtType)
|
||
{
|
||
case "message_start":
|
||
{
|
||
if (evt.TryGetProperty("message", out var msg) &&
|
||
msg.TryGetProperty("usage", out var u))
|
||
TrackUsage(usage, u);
|
||
break;
|
||
}
|
||
|
||
case "content_block_start":
|
||
{
|
||
var idx = evt.GetProperty("index").GetInt32();
|
||
var cb = evt.GetProperty("content_block");
|
||
var cbType = cb.GetProperty("type").GetString() ?? "";
|
||
blockTypes[idx] = cbType;
|
||
|
||
if (cbType == "text")
|
||
{
|
||
currentTextBlock.Clear();
|
||
await SseHelper.TryWriteSse(response, "text_start", new { index = idx }, sseCt);
|
||
}
|
||
else if (cbType == "tool_use")
|
||
{
|
||
if (currentTextBlock.Length > 0)
|
||
{
|
||
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
|
||
currentTextBlock.Clear();
|
||
}
|
||
currentToolId = cb.GetProperty("id").GetString() ?? "";
|
||
currentToolName = cb.GetProperty("name").GetString() ?? "";
|
||
currentToolInputJson.Clear();
|
||
await SseHelper.TryWriteSse(response, "tool_start", new { name = currentToolName, id = currentToolId }, sseCt);
|
||
}
|
||
else if (cbType == "server_tool_use")
|
||
{
|
||
if (currentTextBlock.Length > 0)
|
||
{
|
||
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
|
||
currentTextBlock.Clear();
|
||
}
|
||
currentToolId = cb.GetProperty("id").GetString() ?? "";
|
||
currentToolName = cb.GetProperty("name").GetString() ?? "";
|
||
currentToolInputJson.Clear();
|
||
await SseHelper.TryWriteSse(response, "web_search_start", new { name = currentToolName, id = currentToolId }, sseCt);
|
||
}
|
||
else if (cbType == "web_search_tool_result")
|
||
{
|
||
var rawBlock = JsonSerializer.Deserialize<JsonElement>(cb.GetRawText());
|
||
webSearchResultBlocks[idx] = rawBlock;
|
||
webSearchSources[idx] = ExtractWebSearchSources(cb);
|
||
}
|
||
else if (cbType.EndsWith("_tool_result"))
|
||
{
|
||
// Generische Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
|
||
serverToolResultBlocks[idx] = JsonSerializer.Deserialize<JsonElement>(cb.GetRawText());
|
||
}
|
||
break;
|
||
}
|
||
|
||
case "content_block_delta":
|
||
{
|
||
var delta = evt.GetProperty("delta");
|
||
var deltaType = delta.GetProperty("type").GetString() ?? "";
|
||
|
||
if (deltaType == "text_delta")
|
||
{
|
||
var text = delta.GetProperty("text").GetString() ?? "";
|
||
currentTextBlock.Append(text);
|
||
await SseHelper.TryWriteSse(response, "text_delta", new { text }, sseCt);
|
||
}
|
||
else if (deltaType == "input_json_delta")
|
||
{
|
||
var partial = delta.GetProperty("partial_json").GetString() ?? "";
|
||
currentToolInputJson.Append(partial);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case "content_block_stop":
|
||
{
|
||
var idx = evt.GetProperty("index").GetInt32();
|
||
if (blockTypes.TryGetValue(idx, out var bt))
|
||
{
|
||
if (bt == "text")
|
||
{
|
||
var text = currentTextBlock.ToString();
|
||
if (text.Length > 0)
|
||
{
|
||
assistantContent.Add(new { type = "text", text });
|
||
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
|
||
}
|
||
currentTextBlock.Clear();
|
||
}
|
||
else if (bt == "tool_use")
|
||
{
|
||
JsonElement inputEl;
|
||
try { inputEl = JsonSerializer.Deserialize<JsonElement>(currentToolInputJson.ToString()); }
|
||
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||
|
||
assistantContent.Add(new
|
||
{
|
||
type = "tool_use",
|
||
id = currentToolId,
|
||
name = currentToolName,
|
||
input = inputEl
|
||
});
|
||
|
||
// ask_user_choice: NICHT ausführen, sondern als SSE-Event an Client senden
|
||
if (currentToolName == ToolService.AskUserChoiceToolName)
|
||
{
|
||
var question = inputEl.TryGetProperty("question", out var q) ? q.GetString() ?? "" : "";
|
||
var choices = new List<string>();
|
||
if (inputEl.TryGetProperty("choices", out var c))
|
||
{
|
||
if (c.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var ch in c.EnumerateArray())
|
||
choices.Add(ch.GetString() ?? "");
|
||
}
|
||
else if (c.ValueKind == JsonValueKind.String)
|
||
{
|
||
// Fallback: Claude hat einen einzelnen String statt Array gesendet
|
||
choices.Add(c.GetString() ?? "");
|
||
}
|
||
}
|
||
|
||
await SseHelper.TryWriteSse(response, "user_choice", new
|
||
{
|
||
toolCallId = currentToolId,
|
||
question,
|
||
choices
|
||
}, sseCt);
|
||
|
||
// Stream restlich konsumieren ohne weitere Verarbeitung
|
||
// (Claude sendet nach dem tool_use noch message_delta mit stop_reason)
|
||
stopReason = "awaiting_user_choice";
|
||
continue;
|
||
}
|
||
|
||
// Cancellation prüfen bevor Tool ausgeführt wird
|
||
workCt.ThrowIfCancellationRequested();
|
||
|
||
string toolResult;
|
||
bool isError = false;
|
||
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, userId: userId, telegramService: _telegramService, apiToken: apiToken); }
|
||
catch (OperationCanceledException) { throw; }
|
||
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
||
|
||
workCt.ThrowIfCancellationRequested();
|
||
executedTools.Add(new ToolResult(currentToolId, toolResult, isError));
|
||
|
||
await SseHelper.TryWriteSse(response, "tool_end", new
|
||
{
|
||
name = currentToolName,
|
||
input = currentToolInputJson.ToString(),
|
||
result = toolResult,
|
||
isError
|
||
}, sseCt);
|
||
}
|
||
else if (bt == "server_tool_use")
|
||
{
|
||
// Server-side tool (Web Search) – Anthropic führt es aus, wir speichern nur die Anfrage
|
||
JsonElement inputEl;
|
||
try { inputEl = JsonSerializer.Deserialize<JsonElement>(currentToolInputJson.ToString()); }
|
||
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||
|
||
assistantContent.Add(new
|
||
{
|
||
type = "server_tool_use",
|
||
id = currentToolId,
|
||
name = currentToolName,
|
||
input = inputEl
|
||
});
|
||
}
|
||
else if (bt == "web_search_tool_result")
|
||
{
|
||
// Web-Search-Ergebnis von Anthropic – als Raw-JSON in assistantContent aufnehmen
|
||
assistantContent.Add(webSearchResultBlocks[idx]);
|
||
var sources = webSearchSources.TryGetValue(idx, out var s) ? s : new List<object>();
|
||
await SseHelper.TryWriteSse(response, "web_search_end", new { id = currentToolId, sources }, sseCt);
|
||
}
|
||
else if (bt.EndsWith("_tool_result") && serverToolResultBlocks.TryGetValue(idx, out var srvResultBlock))
|
||
{
|
||
// Generische Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
|
||
assistantContent.Add(srvResultBlock);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case "message_delta":
|
||
{
|
||
if (evt.TryGetProperty("delta", out var d))
|
||
{
|
||
if (d.TryGetProperty("stop_reason", out var sr))
|
||
{
|
||
// Nicht überschreiben wenn bereits auf awaiting_user_choice gesetzt
|
||
if (stopReason != "awaiting_user_choice")
|
||
stopReason = sr.GetString() ?? "";
|
||
|
||
// Bei problematischen stop_reasons Debug-Infos sammeln
|
||
if (stopReason is "unexpected_end" or "max_tokens")
|
||
{
|
||
var debugInfo = new Dictionary<string, object>
|
||
{
|
||
["event"] = "message_delta",
|
||
["stop_reason"] = stopReason,
|
||
["output_tokens"] = usage.OutputTokens,
|
||
["text_length"] = currentTextBlock.Length,
|
||
["timestamp"] = DateTime.UtcNow.ToString("o")
|
||
};
|
||
if (d.TryGetProperty("stop_sequence", out var ss) && ss.ValueKind != JsonValueKind.Null)
|
||
debugInfo["stop_sequence"] = ss.GetString();
|
||
streamErrors.Add(debugInfo);
|
||
}
|
||
}
|
||
}
|
||
if (evt.TryGetProperty("usage", out var u2) &&
|
||
u2.TryGetProperty("output_tokens", out var ot))
|
||
usage.OutputTokens += ot.GetInt64();
|
||
break;
|
||
}
|
||
|
||
case "error":
|
||
{
|
||
// API Error-Event (z.B. overloaded_error)
|
||
var errorInfo = new Dictionary<string, object>
|
||
{
|
||
["event"] = "error",
|
||
["timestamp"] = DateTime.UtcNow.ToString("o"),
|
||
["raw"] = data
|
||
};
|
||
if (evt.TryGetProperty("error", out var err))
|
||
{
|
||
if (err.TryGetProperty("type", out var errType))
|
||
errorInfo["error_type"] = errType.GetString();
|
||
if (err.TryGetProperty("message", out var errMsg))
|
||
errorInfo["error_message"] = errMsg.GetString();
|
||
}
|
||
streamErrors.Add(errorInfo);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (currentTextBlock.Length > 0)
|
||
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
|
||
|
||
// Stream-Fehler als JSON serialisieren (null wenn keine Fehler)
|
||
string streamErrorInfo = streamErrors.Count > 0
|
||
? JsonSerializer.Serialize(streamErrors)
|
||
: null;
|
||
|
||
return (stopReason, assistantContent, executedTools, streamErrorInfo);
|
||
}
|
||
|
||
// ── Shared Helpers ──
|
||
|
||
// TruncateToolResult → SseHelper.cs
|
||
internal static string TruncateToolResult(string result) => SseHelper.TruncateToolResult(result);
|
||
|
||
/// <summary>
|
||
/// Extrahiert den Text der letzten User-Message aus der Message-Liste.
|
||
/// </summary>
|
||
private static string ExtractLastUserText(List<object> messages)
|
||
{
|
||
for (int i = messages.Count - 1; i >= 0; i--)
|
||
{
|
||
var json = JsonSerializer.Serialize(messages[i]);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (!el.TryGetProperty("role", out var role) || role.GetString() != "user") continue;
|
||
if (!el.TryGetProperty("content", out var content)) continue;
|
||
|
||
if (content.ValueKind == JsonValueKind.String)
|
||
return content.GetString();
|
||
|
||
if (content.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var block in content.EnumerateArray())
|
||
{
|
||
var type = block.TryGetProperty("type", out var t) ? t.GetString() : "";
|
||
if (type == "text" && block.TryGetProperty("text", out var txt))
|
||
return txt.GetString();
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extrahiert Bild-Blöcke aus der letzten User-Message, speichert sie als Temp-Dateien
|
||
/// und gibt Datei-Referenz-Hinweise zurück, die der CLI-Prompt vorangestellt werden.
|
||
/// </summary>
|
||
private static string ExtractLastUserImageRefs(List<object> messages, List<string> tempPaths)
|
||
{
|
||
for (int i = messages.Count - 1; i >= 0; i--)
|
||
{
|
||
var json = JsonSerializer.Serialize(messages[i]);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (!el.TryGetProperty("role", out var role) || role.GetString() != "user") continue;
|
||
if (!el.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) break;
|
||
|
||
var refs = new List<string>();
|
||
foreach (var block in content.EnumerateArray())
|
||
{
|
||
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() : "";
|
||
if (blockType != "image" && blockType != "document") continue;
|
||
|
||
if (!block.TryGetProperty("source", out var source)) continue;
|
||
var mediaType = source.TryGetProperty("media_type", out var mt) ? mt.GetString() ?? "" : "";
|
||
var data = source.TryGetProperty("data", out var d) ? d.GetString() ?? "" : "";
|
||
if (string.IsNullOrEmpty(data)) continue;
|
||
|
||
// Dateiendung aus media_type ableiten
|
||
var ext = mediaType switch
|
||
{
|
||
"image/png" => ".png",
|
||
"image/jpeg" => ".jpg",
|
||
"image/gif" => ".gif",
|
||
"image/webp" => ".webp",
|
||
"application/pdf" => ".pdf",
|
||
_ => ".bin"
|
||
};
|
||
|
||
// Temp-Verzeichnis sicherstellen
|
||
var tempDir = Path.Combine(Path.GetTempPath(), "gott-attachments");
|
||
Directory.CreateDirectory(tempDir);
|
||
|
||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||
var filePath = Path.Combine(tempDir, fileName);
|
||
|
||
try
|
||
{
|
||
File.WriteAllBytes(filePath, Convert.FromBase64String(data));
|
||
tempPaths.Add(filePath);
|
||
|
||
var label = blockType == "image" ? "Screenshot" : "Datei";
|
||
refs.Add($"[{label}: {filePath}]");
|
||
}
|
||
catch
|
||
{
|
||
// Base64-Decode fehlgeschlagen → überspringen
|
||
}
|
||
}
|
||
|
||
if (refs.Count > 0)
|
||
{
|
||
return string.Join(" ", refs) + "\n<hint>Lies die referenzierten Dateien mit dem Read-Tool um sie zu sehen.</hint>";
|
||
}
|
||
break;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Löscht temporäre Bilddateien die für den CLI-Aufruf erstellt wurden.
|
||
/// </summary>
|
||
private static void CleanupTempImages(List<string> paths)
|
||
{
|
||
if (paths == null || paths.Count == 0) return;
|
||
foreach (var path in paths)
|
||
{
|
||
try { if (File.Exists(path)) File.Delete(path); } catch { }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
|
||
/// Enthält den MCP-Server für remote_execute.
|
||
/// </summary>
|
||
private string GenerateMcpConfig(string sessionId)
|
||
{
|
||
// Port aus Konfiguration oder Default
|
||
var port = 19000;
|
||
var scriptPath = Path.Combine(AppContext.BaseDirectory, "scripts", "mcp-remote-execute.py");
|
||
|
||
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
|
||
if (!File.Exists(scriptPath))
|
||
{
|
||
// wwwroot ist typischerweise /app/gott/wwwroot → eine Ebene hoch = Projekt-Root
|
||
var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory;
|
||
var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py");
|
||
if (File.Exists(srcPath)) scriptPath = srcPath;
|
||
}
|
||
|
||
var config = new
|
||
{
|
||
mcpServers = new Dictionary<string, object>
|
||
{
|
||
["gott-remote"] = new
|
||
{
|
||
command = "python3",
|
||
args = new[] { scriptPath },
|
||
env = new Dictionary<string, string>
|
||
{
|
||
["GOTT_API_URL"] = $"http://localhost:{port}",
|
||
["GOTT_SESSION_ID"] = sessionId,
|
||
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
var json = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||
var tempPath = Path.Combine(Path.GetTempPath(), $"gott-mcp-{sessionId}.json");
|
||
File.WriteAllText(tempPath, json);
|
||
return tempPath;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extrahiert Web-Search-Sources aus einem web_search_tool_result-Block.
|
||
/// </summary>
|
||
private static List<object> ExtractWebSearchSources(JsonElement block)
|
||
{
|
||
var sources = new List<object>();
|
||
if (block.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in searchContent.EnumerateArray())
|
||
{
|
||
var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : "";
|
||
if (itemType == "web_search_result")
|
||
{
|
||
var url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : "";
|
||
var title = item.TryGetProperty("title", out var tt) ? tt.GetString() ?? "" : "";
|
||
if (!string.IsNullOrEmpty(url))
|
||
sources.Add(new { url, title });
|
||
}
|
||
}
|
||
}
|
||
return sources;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parst einen server_tool_use-Block und gibt (id, name, input) zurück.
|
||
/// </summary>
|
||
private static (string id, string name, JsonElement input) ParseServerToolUse(JsonElement block)
|
||
{
|
||
var toolId = block.GetProperty("id").GetString() ?? "";
|
||
var toolName = block.GetProperty("name").GetString() ?? "";
|
||
JsonElement inputEl;
|
||
try { inputEl = block.GetProperty("input"); }
|
||
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||
return (toolId, toolName, inputEl);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Aktualisiert den TokenUsage-Tracker aus einem usage-JSON-Element.
|
||
/// </summary>
|
||
private static void TrackUsage(TokenUsage usage, JsonElement 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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fügt Tool-Definitionen und WebSearch zum Payload hinzu.
|
||
/// </summary>
|
||
private void AddToolsToPayload(Dictionary<string, object> payload, string workspace, string agentId, bool useMemory, string webSearchVersion, string toolChoice, int iteration, bool allowUserChoice = false, bool planMode = false, bool includeRemote = false, bool includeGitHubSearch = false, bool includeSerperSearch = false)
|
||
{
|
||
var hasWorkspace = !string.IsNullOrEmpty(workspace);
|
||
var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId);
|
||
var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0;
|
||
var includeSendToSession = _db != null && _chatService != null;
|
||
var hasTools = hasWorkspace || includeMemory || includeRemote || includeMemorySearch || includeSendToSession || includeGitHubSearch || includeSerperSearch;
|
||
var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion);
|
||
|
||
if (!hasTools && !hasWebSearch) return;
|
||
|
||
var tools = new List<object>();
|
||
if (hasTools)
|
||
{
|
||
var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote, includeMemorySearch: includeMemorySearch, includeSendToSession: includeSendToSession, includeGitHubSearch: includeGitHubSearch, includeSerperSearch: includeSerperSearch);
|
||
if (planMode)
|
||
allTools = _toolService.FilterReadOnlyTools(allTools);
|
||
tools.AddRange(allTools);
|
||
}
|
||
if (hasWebSearch)
|
||
{
|
||
tools.Add(new { type = webSearchVersion, name = "web_search", max_uses = 5 });
|
||
// web_search_20260209: Code Execution wird von der API automatisch injected – NICHT manuell hinzufügen!
|
||
}
|
||
payload["tools"] = tools;
|
||
var effectiveChoice = (!planMode && toolChoice == "any" && iteration == 1) ? "any" : "auto";
|
||
payload["tool_choice"] = new { type = effectiveChoice };
|
||
}
|
||
|
||
/// <summary>
|
||
/// Entfernt Messages vom Anfang der Liste, ohne tool_use/tool_result-Paare zu trennen.
|
||
/// Gibt die tatsächliche Anzahl entfernter Messages zurück.
|
||
/// </summary>
|
||
private static int TrimMessagesCleanly(List<object> messages, int targetRemoveCount)
|
||
{
|
||
if (targetRemoveCount <= 0 || targetRemoveCount >= messages.Count)
|
||
return 0;
|
||
|
||
int removeCount = targetRemoveCount;
|
||
|
||
// Sicherstellen, dass wir nicht mitten in einem tool_use/tool_result-Paar abschneiden
|
||
while (removeCount < messages.Count - 1)
|
||
{
|
||
var firstRemaining = messages[removeCount];
|
||
var firstRemainingJson = JsonSerializer.Serialize(firstRemaining);
|
||
|
||
// Wenn die erste verbleibende Message ein tool_result ist → vorherige auch behalten
|
||
if (firstRemainingJson.Contains("\"tool_result\""))
|
||
{
|
||
removeCount--;
|
||
if (removeCount <= 0) return 0;
|
||
continue;
|
||
}
|
||
|
||
// Wenn die letzte entfernte Message ein assistant mit tool_use ist → tool_result auch entfernen
|
||
var lastRemovedJson = JsonSerializer.Serialize(messages[removeCount - 1]);
|
||
if (lastRemovedJson.Contains("\"tool_use\"") && lastRemovedJson.Contains("\"assistant\""))
|
||
{
|
||
removeCount++;
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (removeCount > 0 && removeCount < messages.Count)
|
||
{
|
||
messages.RemoveRange(0, removeCount);
|
||
return removeCount;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
private HttpClient CreateClient(string apiToken)
|
||
{
|
||
var client = _httpFactory.CreateClient();
|
||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||
client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,code-execution-2025-08-25");
|
||
client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)");
|
||
client.DefaultRequestHeaders.Add("x-app", "cli");
|
||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||
client.DefaultRequestHeaders.Add("anthropic-dangerous-direct-browser-access", "true");
|
||
return client;
|
||
}
|
||
|
||
// WriteSseEvent, TryWriteSse, TokenUsage, ToolResult → SseHelper.cs
|
||
|
||
/// <summary>
|
||
/// Streaming-Chat über die Claude CLI.
|
||
/// Die CLI verwendet das lokale Token aus ~/.claude/.credentials.json.
|
||
/// </summary>
|
||
public async Task StreamChatViaCli(
|
||
HttpResponse response,
|
||
string modelId,
|
||
List<object> systemPrompt,
|
||
List<object> messages,
|
||
string chainId,
|
||
string workspace = null,
|
||
bool allowUserChoice = false,
|
||
bool planMode = false,
|
||
bool webSearch = false,
|
||
string sessionId = null,
|
||
RemoteConnectionManager remoteMgr = null,
|
||
CancellationToken workCt = default,
|
||
CancellationToken sseCt = default)
|
||
{
|
||
if (_cliService == null || !_cliService.IsAvailable)
|
||
{
|
||
await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, sseCt);
|
||
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
|
||
return;
|
||
}
|
||
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var usage = new TokenUsage();
|
||
var fullText = new StringBuilder();
|
||
var tempImagePaths = new List<string>();
|
||
string mcpConfigPath = null;
|
||
|
||
var chain = new ChainRecord
|
||
{
|
||
ChainId = chainId,
|
||
SessionId = sessionId,
|
||
Model = modelId
|
||
};
|
||
|
||
try
|
||
{
|
||
// MCP-Config für remote_execute generieren (wenn Remote-Verbindung vorhanden)
|
||
if (remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null)
|
||
{
|
||
mcpConfigPath = GenerateMcpConfig(sessionId);
|
||
}
|
||
|
||
// System-Prompt als Text extrahieren
|
||
var systemText = new StringBuilder();
|
||
if (systemPrompt != null)
|
||
{
|
||
foreach (var block in systemPrompt)
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (el.TryGetProperty("text", out var t))
|
||
systemText.AppendLine(t.GetString());
|
||
}
|
||
}
|
||
|
||
// ask_user_choice Instruktion anhängen
|
||
if (allowUserChoice)
|
||
{
|
||
systemText.AppendLine();
|
||
systemText.AppendLine(@"## Rückfragen
|
||
Wenn du dem User eine Frage mit Auswahlmöglichkeiten stellen möchtest (2-5 Optionen), gib EXAKT diesen Block aus, auf eigenen Zeilen, ohne weiteren Text drumherum:
|
||
|
||
:::ASK_USER_CHOICE:::
|
||
{""question"": ""Deine Frage"", ""choices"": [""Option A"", ""Option B""]}
|
||
:::END_ASK_USER_CHOICE:::
|
||
|
||
Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die Entscheidung wirklich vom User getroffen werden muss.");
|
||
}
|
||
|
||
// Letzte User-Message als Prompt extrahieren
|
||
var prompt = ExtractLastUserText(messages) ?? "";
|
||
|
||
// Bild-Attachments aus letzter User-Message als Temp-Dateien speichern
|
||
// (CLI kann Bilder nicht inline empfangen, aber per Read-Tool lesen)
|
||
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
|
||
if (!string.IsNullOrEmpty(imageRefs))
|
||
{
|
||
prompt = imageRefs + "\n" + prompt;
|
||
}
|
||
|
||
// Konversations-History als Kontext zum Prompt hinzufügen
|
||
var historyContext = BuildHistoryContext(messages);
|
||
if (!string.IsNullOrEmpty(historyContext))
|
||
{
|
||
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
|
||
}
|
||
|
||
bool isUserChoice = false;
|
||
string choiceToolCallId = null;
|
||
|
||
var result = await _cliService.ChatStreamingAsync(
|
||
prompt,
|
||
async evt =>
|
||
{
|
||
switch (evt.Type)
|
||
{
|
||
case CliEventType.TextStart:
|
||
await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
|
||
break;
|
||
case CliEventType.TextDelta:
|
||
fullText.Append(evt.Text);
|
||
await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt);
|
||
break;
|
||
case CliEventType.TextEnd:
|
||
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
|
||
break;
|
||
case CliEventType.ToolStart:
|
||
{
|
||
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
|
||
if (fullText.Length > 0)
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = fullText.ToString()
|
||
});
|
||
fullText.Clear();
|
||
}
|
||
var mappedName = MapCliToolName(evt.ToolName);
|
||
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt);
|
||
break;
|
||
}
|
||
case CliEventType.ToolEnd:
|
||
{
|
||
var mappedName = MapCliToolName(evt.ToolName);
|
||
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
|
||
// Tool-Use in DB speichern (damit Poll die Tools anzeigt)
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "tool_use",
|
||
ToolName = mappedName,
|
||
ToolCallId = evt.ToolId,
|
||
Content = mappedInput
|
||
});
|
||
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
|
||
break;
|
||
}
|
||
case CliEventType.ToolResult:
|
||
{
|
||
// Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist)
|
||
var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? "");
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "user",
|
||
Type = "tool_result",
|
||
ToolCallId = evt.ToolId,
|
||
Content = resultContent,
|
||
IsError = evt.ToolResultIsError
|
||
});
|
||
// SSE Event an Client senden
|
||
await SseHelper.TryWriteSse(response, "tool_result", new
|
||
{
|
||
id = evt.ToolId,
|
||
result = resultContent,
|
||
isError = evt.ToolResultIsError
|
||
}, sseCt);
|
||
break;
|
||
}
|
||
case CliEventType.UserChoice:
|
||
// Text bisher in DB speichern
|
||
if (fullText.Length > 0)
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = fullText.ToString()
|
||
});
|
||
}
|
||
|
||
// Synthetische tool_use speichern (für History-Rebuild)
|
||
choiceToolCallId = $"toolu_cli_{Guid.NewGuid():N}";
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "tool_use",
|
||
ToolName = "ask_user_choice",
|
||
ToolCallId = choiceToolCallId,
|
||
Content = JsonSerializer.Serialize(new { question = evt.ChoiceQuestion, choices = evt.ChoiceOptions })
|
||
});
|
||
|
||
// SSE user_choice Event (Frontend zeigt Buttons)
|
||
await SseHelper.TryWriteSse(response, "user_choice", new
|
||
{
|
||
toolCallId = choiceToolCallId,
|
||
question = evt.ChoiceQuestion,
|
||
choices = evt.ChoiceOptions
|
||
}, sseCt);
|
||
|
||
isUserChoice = true;
|
||
break;
|
||
}
|
||
},
|
||
modelId: modelId,
|
||
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
|
||
workspace: workspace,
|
||
hasWorkspace: !string.IsNullOrEmpty(workspace),
|
||
planMode: planMode,
|
||
webSearch: webSearch,
|
||
mcpConfigPath: mcpConfigPath,
|
||
cancellationToken: workCt
|
||
);
|
||
|
||
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
|
||
if (!result.Success && !isUserChoice)
|
||
{
|
||
var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert";
|
||
// Error in DB speichern (damit er beim Neuladen sichtbar ist)
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "system",
|
||
Type = "error",
|
||
Content = $"CLI-Fehler: {errorMsg}"
|
||
});
|
||
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
|
||
}
|
||
|
||
// Antwort in DB speichern (nur wenn nicht schon im UserChoice-Handler gespeichert)
|
||
if (!isUserChoice && fullText.Length > 0)
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = fullText.ToString()
|
||
});
|
||
}
|
||
|
||
// Token-Usage vom CLI-Result übernehmen
|
||
usage.InputTokens = result.InputTokens;
|
||
usage.OutputTokens = result.OutputTokens;
|
||
usage.CacheReadTokens = result.CacheReadTokens;
|
||
usage.CacheCreationTokens = result.CacheCreationTokens;
|
||
|
||
var stopReason = isUserChoice ? "awaiting_user_choice" : (result.Success ? "end_turn" : "error");
|
||
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
|
||
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
inputTokens = result.InputTokens,
|
||
outputTokens = result.OutputTokens,
|
||
cacheCreationTokens = result.CacheCreationTokens,
|
||
cacheReadTokens = result.CacheReadTokens,
|
||
costUsd = result.CostUsd,
|
||
iterations = result.NumTurns > 0 ? result.NumTurns : 1,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason,
|
||
viaCli = true
|
||
}, sseCt);
|
||
}
|
||
catch (OperationCanceledException) when (workCt.IsCancellationRequested)
|
||
{
|
||
// Stop-Button gedrückt → CLI-Prozess wurde gestoppt
|
||
await FinalizeChain(chain, usage, 1, "stopped", sw);
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason = "stopped",
|
||
viaCli = true
|
||
}, CancellationToken.None);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Client-Disconnect (sseCt gecancelled, aber NICHT workCt)
|
||
// CLI-Prozess läuft weiter im Hintergrund – hier nur Chain finalisieren
|
||
// (sollte nicht mehr passieren, da CLI jetzt workCt nutzt)
|
||
await FinalizeChain(chain, usage, 1, "client_disconnected", sw);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errorMsg = $"CLI-Fehler: {ex.Message}";
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "system",
|
||
Type = "error",
|
||
Content = errorMsg
|
||
});
|
||
await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, sseCt);
|
||
await FinalizeChain(chain, usage, 1, "error", sw);
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason = "error",
|
||
viaCli = true
|
||
}, sseCt);
|
||
}
|
||
finally
|
||
{
|
||
// Temp-Bilddateien aufräumen
|
||
CleanupTempImages(tempImagePaths);
|
||
|
||
// MCP-Config Temp-Datei aufräumen
|
||
if (mcpConfigPath != null)
|
||
{
|
||
try { File.Delete(mcpConfigPath); } catch { }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mappt CLI-Tool-Namen (Edit, Write, Bash, etc.) auf gott-Tool-Namen (edit_file, write_file, etc.).
|
||
/// </summary>
|
||
private static string MapCliToolName(string cliName)
|
||
{
|
||
return cliName switch
|
||
{
|
||
"Edit" => "edit_file",
|
||
"Write" => "write_file",
|
||
"Read" => "read_file",
|
||
"Bash" => "execute",
|
||
"Glob" => "list_files",
|
||
"Grep" => "search_files",
|
||
"WebSearch" => "web_search",
|
||
"WebFetch" => "web_fetch",
|
||
"NotebookEdit" => "notebook_edit",
|
||
"mcp__gott-remote__remote_execute" => "remote_execute",
|
||
_ => cliName?.StartsWith("mcp__") == true ? cliName.Split("__").LastOrDefault() ?? cliName : cliName
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mappt CLI-Tool-Input-Felder auf gott-Konventionen (z.B. file_path→path, old_string→old_text).
|
||
/// </summary>
|
||
private static string MapCliToolInput(string cliToolName, string inputJson)
|
||
{
|
||
if (string.IsNullOrEmpty(inputJson)) return inputJson;
|
||
|
||
try
|
||
{
|
||
var input = JsonSerializer.Deserialize<JsonElement>(inputJson);
|
||
if (input.ValueKind != JsonValueKind.Object) return inputJson;
|
||
|
||
switch (cliToolName)
|
||
{
|
||
case "Edit":
|
||
{
|
||
// file_path → path, old_string → old_text, new_string → new_text
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name switch
|
||
{
|
||
"file_path" => "path",
|
||
"old_string" => "old_text",
|
||
"new_string" => "new_text",
|
||
_ => prop.Name
|
||
};
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Write":
|
||
{
|
||
// file_path → path, content bleibt
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name == "file_path" ? "path" : prop.Name;
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Read":
|
||
{
|
||
// file_path → path
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name == "file_path" ? "path" : prop.Name;
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Bash":
|
||
{
|
||
// command bleibt gleich
|
||
return inputJson;
|
||
}
|
||
default:
|
||
return inputJson;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
return inputJson;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut einen Kontext-String aus der Konversations-History.
|
||
/// </summary>
|
||
private static string BuildHistoryContext(List<object> messages)
|
||
{
|
||
var sb = new StringBuilder();
|
||
// Letzte Message überspringen wenn es ein Text-Prompt ist (wird separat als current_request gesendet).
|
||
// Wenn die letzte Message ein tool_result ist (z.B. Choice-Antwort), NICHT überspringen.
|
||
int takeCount = messages.Count;
|
||
if (messages.Count > 0)
|
||
{
|
||
var lastJson = JsonSerializer.Serialize(messages[messages.Count - 1]);
|
||
var lastEl = JsonSerializer.Deserialize<JsonElement>(lastJson);
|
||
bool lastIsToolResult = false;
|
||
if (lastEl.TryGetProperty("content", out var lc) && lc.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var b in lc.EnumerateArray())
|
||
{
|
||
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
|
||
if (bType == "tool_result") { lastIsToolResult = true; break; }
|
||
}
|
||
}
|
||
if (!lastIsToolResult)
|
||
takeCount = messages.Count - 1;
|
||
}
|
||
foreach (var msg in messages.Take(takeCount))
|
||
{
|
||
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)
|
||
{
|
||
sb.AppendLine($"[{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" && block.TryGetProperty("text", out var txt))
|
||
{
|
||
sb.AppendLine($"[{roleStr}]: {txt.GetString()}");
|
||
}
|
||
else if (blockType == "tool_use" && block.TryGetProperty("name", out var tn) && tn.GetString() == "ask_user_choice")
|
||
{
|
||
if (block.TryGetProperty("input", out var inp))
|
||
{
|
||
var q = inp.TryGetProperty("question", out var qq) ? qq.GetString() ?? "" : "";
|
||
var opts = new List<string>();
|
||
if (inp.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
||
foreach (var o in cc.EnumerateArray()) opts.Add(o.GetString() ?? "");
|
||
sb.AppendLine($"[assistant]: Ich habe gefragt: \"{q}\" mit Optionen: {string.Join(", ", opts)}");
|
||
}
|
||
}
|
||
else if (blockType == "tool_result" && block.TryGetProperty("content", out var trContent))
|
||
{
|
||
sb.AppendLine($"[user]: Du hast gewählt: \"{trContent.GetString()}\"");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return sb.ToString().Trim();
|
||
}
|
||
}
|