Files
gott/Services/AnthropicService.Cli.cs
T
MelekiandClaude Fable 5 81892d6e65 Agent: Max Output Tokens + Thinking deaktivieren einstellbar
Zwei neue Agent-Einstellungen (Frontend, DB-Migration additiv):
- MaxOutputTokens: hartes Ausgabelimit. CLI: CLAUDE_CODE_MAX_OUTPUT_TOKENS,
  API: max_tokens (statt hart 16384). 0 = aus.
- DisableThinking: CLI: CLAUDE_CODE_DISABLE_THINKING=1 + MAX_THINKING_TOKENS=0;
  entfernt CLAUDE_CODE_EFFORT_LEVEL (Explizit-Aus schlaegt Effort).
  API-Pfad ohne Aequivalent (Hint im UI).

Die einzigen zwei Parameter, die z.ai/GLM nachweislich honoriert
(Sampling-Parameter wie temperature/top_p werden dort still verworfen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 17:54:11 +02:00

1078 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Endpoints;
using Gott.Models;
using Microsoft.AspNetCore.Http;
namespace Gott.Services;
public partial class AnthropicService
{
private static string _cachedPythonCmd;
private static bool _pythonCmdChecked;
/// <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 ultracode = false,
bool webSearch = false,
string sessionId = null,
string agentId = null,
RemoteConnectionManager remoteMgr = null,
string mcpServers = null,
bool useAutoContext = true,
string effort = null,
int maxOutputTokens = 0,
bool disableThinking = false,
TokenUsage seedUsage = 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();
// Panel-Member-Tokens (Fusion/MoA) vorab in die Chain-Usage falten → erscheinen im
// done-Event (Live-Badge) UND in FinalizeChain (DB, überlebt F5). Null/leer = No-op.
if (seedUsage != null) usage.Add(seedUsage);
// Token-Fix bei Abbruch: Die Token stammen aus dem finalen result-Event – das kommt bei einem
// Abbruch NIE, also bliebe usage 0. Wir akkumulieren die Orchestrator-Usage live aus den
// assistant-Turns (CliEventType.Usage) in partialUsage und nehmen bei Abbruch/Zwischen-Persistenz
// das jeweils Größere pro Feld (kein Doppelzählen mit dem normalen result-Pfad).
var partialUsage = new TokenUsage();
TokenUsage EffectiveUsage() => new TokenUsage
{
InputTokens = Math.Max(usage.InputTokens, partialUsage.InputTokens),
OutputTokens = Math.Max(usage.OutputTokens, partialUsage.OutputTokens),
CacheCreationTokens = Math.Max(usage.CacheCreationTokens, partialUsage.CacheCreationTokens),
CacheReadTokens = Math.Max(usage.CacheReadTokens, partialUsage.CacheReadTokens)
};
var lastWfFlush = DateTime.MinValue; // Throttle für die inkrementelle Workflow-Persistenz
var fullText = new StringBuilder();
var tempImagePaths = new List<string>();
string mcpConfigPath = null;
bool isUserChoice = false;
CancellationTokenSource segCts = null; // aktuelles CLI-Segment (Kill-and-Restart-Steering)
var chain = new ChainRecord
{
ChainId = chainId,
SessionId = sessionId,
Model = modelId
};
try
{
// MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute)
var remoteConn = remoteMgr != null && !string.IsNullOrEmpty(sessionId) ? remoteMgr.GetBySessionId(sessionId) : null;
var hasRemote = remoteConn != null;
var remoteOs = remoteConn?.OsVersion ?? "";
var mcpServerList = ClaudeCliService.ParseMcpServers(mcpServers);
mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote, mcpServerList, remoteOs, modelId, webSearch);
// Python-Verfügbarkeit prüfen und ggf. warnen
if (FindPythonCommand() == null)
await SseHelper.TryWriteSse(response, "warning", new { message = "⚠ Python wurde nicht gefunden (python3/python/py). MCP-Tools (Remote-Execute, Memory, etc.) sind nicht verfügbar." }, sseCt);
// 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());
}
}
// ── Automatische Kontext-Injektion via Embedding-Suche (wie in StreamChat) ──
if (useAutoContext && messages.Count > 0)
{
try
{
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId);
if (autoCtx != null)
{
systemText.AppendLine(autoCtx);
await SseHelper.TryWriteSse(response, "auto_context", new { context = autoCtx }, sseCt);
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Role = "system",
Type = "auto_context",
Content = autoCtx
});
}
}
catch (Exception ex)
{
Console.WriteLine($"[AutoContext/CLI] Error: {ex.Message}");
}
}
// ── Resume-Hinweis: Lief in dieser Session zuletzt ein Workflow, der ABGEBROCHEN wurde,
// injizieren wir seine runId in den System-Prompt. Der Agent kann den Workflow dann mit
// resumeFromRunId erneut starten → bereits fertige Subagenten werden aus dem Journal
// wiederverwendet statt neu ausgeführt. Best-effort (hängt an LLM-Kooperation). Nur bei
// ultracode relevant (nur dann kann der Agent überhaupt das Workflow-Tool nutzen).
if (ultracode && !string.IsNullOrEmpty(sessionId))
{
try
{
var priorChains = await _db.GetActiveChains(sessionId); // ORDER BY id DESC
// Nur der ZULETZT gelaufene Workflow zählt: ist er abgeschlossen → kein Resume;
// wurde er abgebrochen → Hinweis anbieten.
var lastWf = priorChains.FirstOrDefault(c =>
c.ChainId != chainId && !string.IsNullOrEmpty(c.WorkflowRunId));
if (lastWf != null && lastWf.StopReason != "end_turn"
&& !(lastWf.WorkflowInfo != null && lastWf.WorkflowInfo.Contains("\"Status\":\"completed\"")))
{
systemText.AppendLine(
$"\n<workflow_resume>\nIn dieser Session wurde zuletzt ein dynamischer Workflow (runId: {lastWf.WorkflowRunId}) vorzeitig beendet (Grund: {lastWf.StopReason ?? "unbekannt"}) und ist unvollständig. Falls die aktuelle Aufgabe die Fortsetzung genau dieses Workflows ist: starte ihn erneut mit DEMSELBEN Skript und übergib dem Workflow-Tool resumeFromRunId: \"{lastWf.WorkflowRunId}\" – bereits abgeschlossene Subagenten werden dann aus dem Journal wiederverwendet statt neu ausgeführt (spart Token und Zeit). Ist die aktuelle Aufgabe NICHT die Fortsetzung, ignoriere diesen Hinweis vollständig.\n</workflow_resume>");
await SseHelper.TryWriteSse(response, "workflow_resume_available",
new { runId = lastWf.WorkflowRunId, stopReason = lastWf.StopReason }, sseCt);
}
}
catch (Exception ex) { Console.WriteLine($"[Wf-Resume/CLI] Error: {ex.Message}"); }
}
// 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>";
}
string choiceToolCallId = null;
// ── Kill-and-Restart-Steering ──
// Der Agentic-Loop läuft INNERHALB von `claude -p` – gott sieht nur Events,
// echtes Mid-Turn-Inject ist über die CLI nicht möglich (Einwürfe würden nur
// gequeued). Stattdessen: kommt während eines Laufs ein Einwurf rein, killen wir
// den Child am nächsten Tool-Boundary, hängen den bisherigen Verlauf + den Einwurf
// an den Prompt und starten die CLI neu (Abbruch-Semantik – mehr geht über die CLI nicht).
var basePrompt = prompt;
var currentPrompt = prompt;
int totalTurns = 0;
double totalCost = 0;
int steerRestartCount = 0;
CliChatResult result = null;
bool steerRestart;
bool segSteerTriggered = false;
// Event-Handler als lokale Funktion (damit der Restart-Loop flach bleibt)
async Task HandleCliEvent(CliStreamEvent 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)
{
var textForEmbed = fullText.ToString();
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = textForEmbed
});
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed);
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);
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
if (mappedName == "ask_user_choice")
{
try
{
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
var cChoices = new List<string>();
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
foreach (var cOpt in cc.EnumerateArray())
cChoices.Add(cOpt.GetString() ?? "");
// Alte Antwort entfernen (falls vorhanden)
InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _);
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
choiceToolCallId = evt.ToolId;
await SseHelper.TryWriteSse(response, "user_choice", new
{
toolCallId = evt.ToolId,
question = cQuestion,
choices = cChoices,
viaCli = true
}, sseCt);
isUserChoice = true;
}
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
}
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);
// ask_user_choice wurde gerade beantwortet (Result des Choice-Tools kam zurück)
// → Choice-Pause aufheben: die CLI nimmt die Arbeit in DERSELBEN Invocation
// wieder auf, also muss Steering ab hier wieder greifen. Ohne diesen Reset
// bliebe isUserChoice für den Rest des Laufs true und ein Einwurf käme erst
// ganz am Chain-Ende (als Close()-Leftover) an statt am nächsten Boundary.
if (isUserChoice && choiceToolCallId != null && evt.ToolId == choiceToolCallId)
{
isUserChoice = false;
choiceToolCallId = null;
}
// Steering: Tool-Boundary erreicht – steht ein Einwurf an, Child killen
// (löst OperationCanceledException aus → Restart unten).
if (!segSteerTriggered && !isUserChoice
&& _steeringQueue != null && !string.IsNullOrEmpty(sessionId)
&& _steeringQueue.HasPending(sessionId))
{
segSteerTriggered = true;
try { segCts?.Cancel(); } catch { }
}
break;
}
case CliEventType.Usage:
// Orchestrator-Usage live akkumulieren (Token-Fix-Fallback für Abbruch):
// Output/Cache summieren (pro Turn additiv), Input = größter Turn-Wert
// (Kontextgröße wächst pro Turn – Summieren würde massiv doppelt zählen).
partialUsage.OutputTokens += evt.OutputTokens;
partialUsage.CacheReadTokens += evt.CacheReadTokens;
partialUsage.CacheCreationTokens += evt.CacheCreationTokens;
if (evt.InputTokens > partialUsage.InputTokens) partialUsage.InputTokens = evt.InputTokens;
break;
}
// Inkrementelle Zwischen-Persistenz (throttled) – so überlebt der letzte Workflow-Stand
// (Subagent-Outputs + Token) auch einen Hard-Kill (OOM/Redeploy), bei dem KEIN catch läuft.
await MaybeFlushWorkflowProgress();
}
// Persistiert – höchstens alle ~6 s – die aktuelle Workflow-Telemetrie dieser Chain in die DB.
async Task MaybeFlushWorkflowProgress()
{
if (_workflowTracker == null || string.IsNullOrEmpty(sessionId)) return;
if (_workflowTracker.ChainIdFor(sessionId) != chainId) return; // nur bei Workflow FÜR diese Chain
var now = DateTime.UtcNow;
if ((now - lastWfFlush).TotalSeconds < 6) return; // Throttle
lastWfFlush = now;
try
{
var run = _workflowTracker.Get(sessionId);
var wp = WorkflowTelemetryReader.Read(run);
if (wp == null) return;
wp.ChainId = chainId;
await _db.UpdateChainWorkflowProgress(chainId, JsonSerializer.Serialize(wp), run?.RunId, EffectiveUsage());
}
catch { /* inkrementelle Persistenz darf den Stream nie stören */ }
}
do
{
steerRestart = false;
segSteerTriggered = false;
segCts?.Dispose();
segCts = CancellationTokenSource.CreateLinkedTokenSource(workCt);
try
{
result = await _cliService.ChatStreamingAsync(
currentPrompt,
HandleCliEvent,
modelId: modelId,
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
workspace: workspace,
hasWorkspace: !string.IsNullOrEmpty(workspace),
planMode: planMode,
ultracode: ultracode,
webSearch: webSearch,
mcpConfigPath: mcpConfigPath,
mcpServerNames: mcpServerList.Select(s => s.Name).ToList(),
effort: effort,
maxOutputTokens: maxOutputTokens,
disableThinking: disableThinking,
sessionId: sessionId,
chainId: chainId,
cancellationToken: segCts.Token
);
}
catch (OperationCanceledException) when (segSteerTriggered && !workCt.IsCancellationRequested)
{
// Steering-Kill (kein echter Abbruch) → Restart unten vorbereiten
result = null;
}
// Tokens des abgeschlossenen Segments akkumulieren (bei Kill: result == null)
if (result != null)
{
usage.InputTokens += result.InputTokens;
usage.OutputTokens += result.OutputTokens;
usage.CacheReadTokens += result.CacheReadTokens;
usage.CacheCreationTokens += result.CacheCreationTokens;
totalCost += result.CostUsd;
totalTurns += result.NumTurns > 0 ? result.NumTurns : 1;
}
// Restart nötig? (a) Kill am Tool-Boundary, (b) Einwurf kam erst am Turn-Ende rein
bool steerPending = !isUserChoice && _steeringQueue != null
&& !string.IsNullOrEmpty(sessionId) && _steeringQueue.HasPending(sessionId);
if (segSteerTriggered || steerPending)
{
steerRestart = true;
Console.WriteLine($"[CLI-Steering] Restart #{++steerRestartCount} (chain {chainId})");
// 1. Bisher gepufferten Assistant-Text dieses Segments persistieren
if (fullText.Length > 0)
{
var partial = fullText.ToString();
var pid = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = 1,
Role = "assistant", Type = "text", Content = partial
});
_embeddingBg?.Enqueue(pid, chainId, sessionId, agentId, "assistant", partial);
fullText.Clear();
}
// 2. Verlauf dieses Durchlaufs aus der DB als Transkript (vor dem Persistieren der neuen Einwürfe)
var progressLog = await BuildCliProgressLog(chainId);
// 3. Einwürfe entnehmen, RAW persistieren (Type=steer), via SSE melden, gewrappt sammeln
var steers = _steeringQueue.Drain(sessionId);
var wrapped = new StringBuilder();
foreach (var steer in steers)
{
var sid = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = 1,
Role = "user", Type = "steer", Content = steer
});
_embeddingBg?.Enqueue(sid, chainId, sessionId, agentId, "user", steer);
await SseHelper.TryWriteSse(response, "steer_injected", new { text = steer }, sseCt);
wrapped.AppendLine(WrapSteeringMessage(steer));
}
// 4. Restart-Prompt: Basis-Prompt + bisheriger Verlauf + Einwürfe
var sb = new StringBuilder(basePrompt);
if (!string.IsNullOrEmpty(progressLog))
{
sb.Append("\n\n<work_in_progress>\nDu arbeitest bereits an obiger Aufgabe. Bisheriger Verlauf in diesem Durchlauf (Text, Tool-Aufrufe, Ergebnisse):\n");
sb.Append(progressLog);
sb.Append("\n\nWICHTIG: Setze die Aufgabe JETZT aktiv fort und bringe sie in diesem Durchlauf zu Ende. Bestätige den Einwurf NICHT nur und beende dann den Turn – arbeite die noch offenen Schritte selbst weiter ab. Falls du zuvor Hintergrund-/Async-Operationen gestartet hattest (z.B. Background-Bash-Tasks, lange Sleeps): diese wurden durch den Neustart beendet und existieren NICHT mehr – warte nicht passiv auf sie, sondern starte sie neu, falls für die Fertigstellung nötig.\n</work_in_progress>");
}
sb.Append("\n\n");
sb.Append(wrapped.ToString().Trim());
currentPrompt = sb.ToString();
}
}
while (steerRestart && !workCt.IsCancellationRequested);
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
if (result != null && !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 (finaler Text des letzten Segments)
if (fullText.Length > 0)
{
var finalText = fullText.ToString();
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = finalText
});
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", finalText);
}
// Token-Usage wurde segmentweise in usage/totalTurns/totalCost akkumuliert (siehe Restart-Loop)
var stopReason = isUserChoice ? "awaiting_user_choice" : (result != null && result.Success ? "end_turn" : "error");
await FinalizeChain(chain, usage, totalTurns > 0 ? totalTurns : 1, stopReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
cacheCreationTokens = usage.CacheCreationTokens,
cacheReadTokens = usage.CacheReadTokens,
costUsd = totalCost,
iterations = totalTurns > 0 ? totalTurns : 1,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason,
effort,
viaCli = true
}, sseCt);
}
catch (OperationCanceledException) when (workCt.IsCancellationRequested)
{
// Stop-Button gedrückt → CLI-Prozess wurde gestoppt
// Wenn ask_user_choice aktiv war: als awaiting_user_choice markieren,
// damit die nächste Chain den Kontext (Frage + Optionen) behält.
var stoppedReason = isUserChoice ? "awaiting_user_choice" : "stopped";
await FinalizeChain(chain, EffectiveUsage(), 1, stoppedReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = stoppedReason,
effort,
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, EffectiveUsage(), 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, EffectiveUsage(), 1, "error", sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "error",
effort,
viaCli = true
}, sseCt);
}
finally
{
segCts?.Dispose();
// Temp-Bilddateien aufräumen
CleanupTempImages(tempImagePaths);
// MCP-Config Temp-Datei aufräumen
if (mcpConfigPath != null)
{
try { File.Delete(mcpConfigPath); } catch { }
}
}
}
/// <summary>
/// Baut aus den bereits gespeicherten Messages einer Chain ein kompaktes Verlauf-Transkript
/// für den Kill-and-Restart-Steering-Prompt (was hat das Modell in diesem Durchlauf bereits getan).
/// </summary>
private async Task<string> BuildCliProgressLog(string chainId)
{
var msgs = await _db.GetChainMessages(chainId);
var sb = new StringBuilder();
foreach (var m in msgs)
{
switch (m.Type)
{
case "text":
if (m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content))
sb.AppendLine("[Du]: " + m.Content.Trim());
break;
case "tool_use":
sb.AppendLine($"[Du hast aufgerufen: {m.ToolName}] {TruncateForLog(m.Content, 600)}");
break;
case "tool_result":
sb.AppendLine($"[Ergebnis{(m.IsError ? " (Fehler)" : "")}]: {TruncateForLog(m.Content, 600)}");
break;
case "steer":
sb.AppendLine("[Vorheriger Live-Einwurf des Users]: " + (m.Content ?? "").Trim());
break;
}
}
return sb.ToString().Trim();
}
private static string TruncateForLog(string s, int max)
{
if (string.IsNullOrEmpty(s)) return "";
s = s.Replace("\r", " ").Replace("\n", " ").Trim();
return s.Length <= max ? s : s.Substring(0, max) + " …[gekürzt]";
}
/// <summary>
/// Non-Streaming Chat über die Claude CLI für Telegram (und andere Caller, die kein SSE brauchen).
/// Sammelt den finalen Text und gibt ihn zurück. Persistiert Text, Tool-Use und Tool-Result in der DB,
/// damit die History im Web-UI korrekt rekonstruiert werden kann.
/// </summary>
public async Task<string> ChatNonStreamingViaCli(
ChatContext ctx,
List<object> messages,
Func<int, Task> onIteration = null,
CancellationToken cancellationToken = default)
{
if (_cliService == null || !_cliService.IsAvailable)
return "";
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
var tempImagePaths = new List<string>();
var chain = new ChainRecord
{
ChainId = ctx.ChainId,
SessionId = ctx.SessionId,
Model = ctx.ModelId
};
try
{
// System-Prompt als Text
var systemText = new StringBuilder(ctx.SystemPromptText ?? "");
// Auto-Context (analog Streaming-Pfad)
if ((ctx.Agent?.UseAutoContext ?? true) && messages.Count > 0)
{
try
{
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, ctx.ChainId, ctx.AgentId);
if (!string.IsNullOrEmpty(autoCtx))
{
systemText.AppendLine(autoCtx);
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Role = "system",
Type = "auto_context",
Content = autoCtx
});
}
}
catch { /* silent – Auto-Context optional */ }
}
// Prompt aus letzter User-Message + Bild-/PDF-Refs + History-Kontext
var prompt = ExtractLastUserText(messages) ?? "";
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
if (!string.IsNullOrEmpty(imageRefs))
prompt = imageRefs + "\n" + prompt;
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>";
var fullText = new StringBuilder();
if (onIteration != null) await onIteration(1);
var result = await _cliService.ChatStreamingAsync(
prompt,
async evt =>
{
switch (evt.Type)
{
case CliEventType.TextDelta:
fullText.Append(evt.Text);
break;
case CliEventType.ToolStart:
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
if (fullText.Length > 0)
{
var textForEmbed = fullText.ToString();
var preMsgId = await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = textForEmbed
});
_embeddingBg?.Enqueue(preMsgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", textForEmbed);
fullText.Clear();
}
if (onIteration != null) await onIteration(1);
break;
case CliEventType.ToolEnd:
{
var mappedName = MapCliToolName(evt.ToolName);
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 1,
Role = "assistant",
Type = "tool_use",
ToolName = mappedName,
ToolCallId = evt.ToolId,
Content = mappedInput
});
break;
}
case CliEventType.ToolResult:
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 1,
Role = "user",
Type = "tool_result",
ToolCallId = evt.ToolId,
Content = evt.ToolResultContent ?? "",
IsError = evt.ToolResultIsError
});
break;
}
},
modelId: ctx.ModelId,
systemPrompt: systemText.ToString(),
workspace: ctx.Workspace,
hasWorkspace: !string.IsNullOrEmpty(ctx.Workspace),
effort: ctx.Effort,
cancellationToken: cancellationToken
);
// Finalen Text persistieren
var finalText = fullText.ToString();
if (finalText.Length > 0)
{
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = finalText
});
_embeddingBg?.Enqueue(msgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", finalText);
}
usage.InputTokens = result.InputTokens;
usage.OutputTokens = result.OutputTokens;
usage.CacheReadTokens = result.CacheReadTokens;
usage.CacheCreationTokens = result.CacheCreationTokens;
var stopReason = result.Success ? "end_turn" : "error";
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
return finalText;
}
catch (Exception ex)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId, Iteration = 1,
Role = "system", Type = "error",
Content = $"CLI-Fehler: {ex.Message}", IsError = true
});
await FinalizeChain(chain, usage, 1, "error", sw);
return "";
}
finally
{
CleanupTempImages(tempImagePaths);
}
}
/// <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",
"TodoWrite" => "todo_write",
_ => 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
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();
}
/// <summary>
/// Sucht einen verfügbaren Python-Befehl (python3, python, py).
/// Gibt null zurück, wenn keiner gefunden wird.
/// </summary>
private static string FindPythonCommand()
{
if (_pythonCmdChecked) return _cachedPythonCmd;
foreach (var cmd in new[] { "python3", "python", "py" })
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = cmd,
Arguments = "--version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc != null)
{
proc.WaitForExit(3000);
if (proc.ExitCode == 0)
{
_cachedPythonCmd = cmd;
_pythonCmdChecked = true;
Console.WriteLine($"[MCP] Python gefunden: {cmd}");
return cmd;
}
}
}
catch { /* Command not found – try next */ }
}
_pythonCmdChecked = true;
_cachedPythonCmd = null;
return null;
}
/// <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, string agentId = null, bool hasRemote = false, List<McpServerDef> userMcpServers = null, string remoteOs = null, string modelId = null, bool webSearch = false)
{
// 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))
{
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 env = new Dictionary<string, string>
{
["GOTT_API_URL"] = $"http://localhost:{port}",
["GOTT_SESSION_ID"] = sessionId ?? "",
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken,
["GOTT_AGENT_ID"] = agentId ?? "",
["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false",
["GOTT_REMOTE_OS"] = remoteOs ?? ""
};
var pythonCmd = FindPythonCommand();
var mcpServers = new Dictionary<string, object>
{
["gott-remote"] = new
{
command = pythonCmd ?? "python3",
args = new[] { scriptPath },
env
}
};
if (pythonCmd == null)
Console.WriteLine("[MCP] ⚠ Python nicht gefunden (python3/python/py) – MCP-Tools werden nicht funktionieren!");
// Benutzerdefinierte MCP-Server (pro Session, URL-basiert: http/sse + optionaler Token).
// Claude CLI erfordert expliziten "type" – "http" für streamableHttp, "sse" für SSE.
// Namen sind in ParseMcpServers bereits gesäubert + von reservierten Namen befreit.
if (userMcpServers != null)
{
foreach (var srv in userMcpServers)
{
if (srv == null || string.IsNullOrWhiteSpace(srv.Url)) continue;
// URL-basierte Typ-Erkennung: /sse → SSE-Transport, sonst HTTP (streamableHttp)
var mcpType = srv.Url.TrimEnd('/').EndsWith("/sse", StringComparison.OrdinalIgnoreCase) ? "sse" : "http";
var srvConfig = new Dictionary<string, object>
{
["type"] = mcpType,
["url"] = srv.Url
};
if (!string.IsNullOrEmpty(srv.Token))
{
srvConfig["headers"] = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {srv.Token}"
};
}
mcpServers[srv.Name] = srvConfig;
}
}
// z.ai Web-Search-Prime-MCP: GLM-Modelle können Anthropics native WebSearch nicht nutzen.
// Läuft ein GLM-Modell mit aktivierter Web-Suche und ist der z.ai-Key gesetzt, registrieren
// wir z.ais HTTP-MCP-Suchserver (Bearer-Auth mit demselben Key). Tool wird in den
// --allowed-tools der CLI (ClaudeCliService) als "mcp__web-search-prime__*" freigegeben.
if (webSearch && ClaudeCliService.IsZaiModel(modelId))
{
var zaiKey = _settings?.Get("ZaiApiKey");
if (!string.IsNullOrEmpty(zaiKey))
{
mcpServers["web-search-prime"] = new Dictionary<string, object>
{
["type"] = "http",
["url"] = "https://api.z.ai/api/mcp/web_search_prime/mcp",
["headers"] = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {zaiKey}"
}
};
}
}
var config = new { mcpServers };
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;
}
}