From 1526891cd91a95337816172188d4c206f827daa7 Mon Sep 17 00:00:00 2001 From: Meleki Date: Fri, 17 Apr 2026 10:11:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20CLI-Fallback=20f=C3=BCr=20Sub-Agents=20?= =?UTF-8?q?(Preparer/Review/Finalizer)=20wenn=20kein=20API-Key=20zugewiese?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statt Hard-Error "Kein API-Key zugewiesen" nutzen Sub-Agents jetzt Claude CLI als Fallback. Preparer wird nur übersprungen wenn weder Key noch CLI verfügbar sind. Co-Authored-By: Claude Opus 4.6 --- Endpoints/ChatEndpoint.cs | 15 ++-- Services/AnthropicService.Agents.cs | 103 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index ecdc5a4..56153f1 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -142,14 +142,14 @@ public static class ChatEndpoint if (ctx.Preparer != null && !isChoiceResponse) { var preparerApiToken = userApiKey?.ApiKey; - // Anthropic-Preparer braucht zwingend einen Key, OpenAI-Provider können ohne auskommen - if (string.IsNullOrEmpty(ctx.Preparer.Provider) && string.IsNullOrEmpty(preparerApiToken)) - { - res.StatusCode = 400; - await res.WriteAsJsonAsync(new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte weise einen Key zu." }); - return; - } + // Kein Key und kein CLI → Preparer überspringen (main agent wird auch CLI nutzen oder fehlermelden) + bool skipPreparer = string.IsNullOrEmpty(ctx.Preparer.Provider) + && string.IsNullOrEmpty(preparerApiToken) + && !anthropic.IsCliAvailable; + + if (!skipPreparer) + { // Preparer bekommt History MIT seinen eigenen vorherigen Messages (Tag "preparer"), // damit er den Kontext seiner Rückfragen kennt var preparerHistory = await chatService.BuildHistoryFromDb(sessionId, includeTag: "preparer", promptBlocks: ctx.PromptBlocks); @@ -182,6 +182,7 @@ public static class ChatEndpoint Content = $"[Vorbereitet von {ctx.Preparer.Agent?.Name ?? "Vorbereiter-Agent"}]:\n{preparerResult.ForwardedPrompt}" }); } + } // !skipPreparer } // History für den Session-Agent: OHNE Sub-Agent-Messages (saubere History) diff --git a/Services/AnthropicService.Agents.cs b/Services/AnthropicService.Agents.cs index 20ba09e..d14acf8 100644 --- a/Services/AnthropicService.Agents.cs +++ b/Services/AnthropicService.Agents.cs @@ -63,6 +63,13 @@ public partial class AnthropicService 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( @@ -164,6 +171,13 @@ public partial class AnthropicService 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( @@ -228,6 +242,13 @@ public partial class AnthropicService 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( @@ -624,6 +645,88 @@ public partial class AnthropicService }); } + /// + /// 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. + /// + private async Task<(string text, int iterations)> RunSubAgentViaCli( + HttpResponse response, + SubAgentContext sub, + List 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(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 = $"\n{historyContext}\n\n\n\n{prompt}\n"; + + 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); + } + /// /// Non-Streaming Chat für Telegram. Gibt den finalen Antworttext zurück. /// Wrapper um RunNonStreamingToolLoop mit Chain-Management und Embedding-Enqueue.