Opus 4.5+ and Sonnet 4.6+ support a new "effort" knob that controls how
much budget the model spends on thinking and tool calls. Plumb it through
end to end so each agent can pick its own level.
Storage & shape:
- New TEXT column agents.effort (default '') added via TryAddColumn.
- AgentRecord.Effort + INSERT/UPDATE in DatabaseService.Agents.cs.
- ChatContext.Effort + SubAgentContext.Effort so the value travels with the
request alongside model and tool settings.
Agent editor:
- Effort dropdown above git-commit behavior in the agent form (low/medium/
high/xhigh/max plus empty = "use default"). Always visible; on OpenAI
providers the value is sent but silently ignored.
- agent-page.js load/save and AgentEndpoint create/update wired up.
Anthropic API path:
- StreamChat and RunNonStreamingToolLoop add output_config: { effort } when
effort is set and not "high" – empty/null/"high" means "omit and let the
API pick its default".
- Sub-agents (preparer/review/finalizer) get the parameter through
RunAgentChat so each step uses its own agent's effort.
Anthropic CLI path:
- ClaudeCliService.ChatStreamingAsync takes an effort parameter and exports
CLAUDE_CODE_EFFORT_LEVEL on the spawned process. Env-var beats the
--effort flag because it also covers "max" and "ultracode".
- StreamChatViaCli, ChatNonStreamingViaCli, RunSubAgentViaCli, and the
ChatEndpoint anthropic branches all hand the value through.
ARCHITECTURE.md / ARCHITECTURE_QUICK.md updated with the new field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
896 lines
37 KiB
C#
896 lines
37 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Runtime.InteropServices;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace Gott.Services;
|
||
|
||
/// <summary>
|
||
/// Service für die Kommunikation mit der Claude Code CLI.
|
||
/// Verwendet die CLI als Backend für OAuth-basierte Chats (kein API-Key nötig).
|
||
/// </summary>
|
||
public class ClaudeCliService
|
||
{
|
||
private readonly ILogger<ClaudeCliService> _logger;
|
||
private readonly string _cliPath;
|
||
private readonly bool _isAvailable;
|
||
|
||
public ClaudeCliService(ILogger<ClaudeCliService> logger)
|
||
{
|
||
_logger = logger;
|
||
_cliPath = FindCliPath();
|
||
_isAvailable = !string.IsNullOrEmpty(_cliPath) && File.Exists(_cliPath);
|
||
|
||
if (_isAvailable)
|
||
_logger.LogInformation("Claude CLI gefunden: {Path}", _cliPath);
|
||
else
|
||
_logger.LogWarning("Claude CLI nicht gefunden. CLI-basierte Chats sind nicht verfügbar.");
|
||
}
|
||
|
||
public bool IsAvailable => _isAvailable;
|
||
public string CliPath => _cliPath;
|
||
|
||
/// <summary>
|
||
/// Aktiviert detailliertes Encoding-Debugging für CLI-stdout.
|
||
/// Schreibt alle rohen Bytes vom CLI-Prozess in eine .bin-Datei und
|
||
/// loggt jedes Vorkommen von U+FFFD (Replacement-Char) in eine .log-Datei.
|
||
/// Aktivierung: Umgebungsvariable GOTT_CLI_DEBUG_BYTES=1
|
||
/// </summary>
|
||
private static bool IsByteDebugEnabled()
|
||
{
|
||
var v = Environment.GetEnvironmentVariable("GOTT_CLI_DEBUG_BYTES");
|
||
return !string.IsNullOrEmpty(v) && (v == "1" || string.Equals(v, "true", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
private static string GetDebugDir()
|
||
{
|
||
// data/cli-debug/ relativ zur Anwendung – wird automatisch erstellt
|
||
var dir = Path.Combine(AppContext.BaseDirectory, "data", "cli-debug");
|
||
Directory.CreateDirectory(dir);
|
||
return dir;
|
||
}
|
||
|
||
private ProcessStartInfo CreateCliStartInfo(List<string> args, bool redirectInput = false, string effort = null)
|
||
{
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
FileName = _cliPath,
|
||
RedirectStandardInput = redirectInput,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true,
|
||
StandardOutputEncoding = Encoding.UTF8,
|
||
StandardErrorEncoding = Encoding.UTF8
|
||
};
|
||
|
||
// 1h-Prompt-Cache-TTL für Claude CLI aktivieren (seit CLI 2.1.108).
|
||
// Anthropic hat Anfang März 2026 den Default von 1h auf 5min gesenkt,
|
||
// dieser Flag holt die 1h-TTL zurück (gilt für API-Key, Bedrock, Vertex, Foundry).
|
||
psi.Environment["ENABLE_PROMPT_CACHING_1H"] = "1";
|
||
|
||
// Effort-Level via Env-Var setzen (höchste Priorität, deckt auch "max" und "ultracode" ab).
|
||
// Das "--effort"-Flag unterstützt nur low/medium/high/xhigh – Env-Var deckt alles.
|
||
if (!string.IsNullOrEmpty(effort) && effort != "high")
|
||
psi.Environment["CLAUDE_CODE_EFFORT_LEVEL"] = effort;
|
||
|
||
foreach (var arg in args)
|
||
psi.ArgumentList.Add(arg);
|
||
|
||
return psi;
|
||
}
|
||
|
||
private static string FindCliPath()
|
||
{
|
||
// 1. Expliziter Pfad über Umgebungsvariable (höchste Priorität)
|
||
var envPath = Environment.GetEnvironmentVariable("CLAUDE_CLI_PATH");
|
||
if (!string.IsNullOrEmpty(envPath) && File.Exists(envPath))
|
||
return envPath;
|
||
|
||
// 2. Bekannte Installationspfade
|
||
var possiblePaths = new[]
|
||
{
|
||
// Windows: standalone install
|
||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude-code", "claude.exe"),
|
||
// Windows/Linux: local bin (funktioniert nur wenn Dienst unter dem richtigen User läuft)
|
||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude.exe"),
|
||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude"),
|
||
// Linux: system paths
|
||
"/usr/local/bin/claude",
|
||
"/usr/bin/claude"
|
||
};
|
||
|
||
foreach (var path in possiblePaths)
|
||
{
|
||
if (File.Exists(path))
|
||
return path;
|
||
}
|
||
|
||
// 3. PATH-Suche
|
||
try
|
||
{
|
||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||
foreach (var dir in pathEnv.Split(Path.PathSeparator))
|
||
{
|
||
var exePath = Path.Combine(dir, "claude.exe");
|
||
if (File.Exists(exePath)) return exePath;
|
||
|
||
exePath = Path.Combine(dir, "claude");
|
||
if (File.Exists(exePath)) return exePath;
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Streaming-Chat über die Claude CLI mit Event-basiertem Callback.
|
||
/// Unterstützt Text-Streaming und Tool-Use Events.
|
||
/// </summary>
|
||
public async Task<CliChatResult> ChatStreamingAsync(
|
||
string prompt,
|
||
Func<CliStreamEvent, Task> onEvent,
|
||
string modelId = null,
|
||
string systemPrompt = null,
|
||
string workspace = null,
|
||
bool hasWorkspace = false,
|
||
bool planMode = false,
|
||
bool webSearch = false,
|
||
string mcpConfigPath = null,
|
||
bool hasUnityMcp = false,
|
||
string effort = null,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (!_isAvailable)
|
||
return new CliChatResult { Success = false, Error = "Claude CLI nicht verfügbar" };
|
||
|
||
var args = new List<string>
|
||
{
|
||
"-p",
|
||
"--verbose",
|
||
"--output-format", "stream-json",
|
||
"--include-partial-messages",
|
||
"--no-session-persistence",
|
||
"--permission-mode", "acceptEdits",
|
||
"--disallowed-tools", "AskUserQuestion"
|
||
};
|
||
|
||
// Tools konfigurieren
|
||
var tools = new List<string>();
|
||
if (hasWorkspace && planMode)
|
||
{
|
||
// Plan-Modus: nur read-only Tools
|
||
tools.AddRange(new[] { "Read", "Glob", "Grep" });
|
||
}
|
||
else if (hasWorkspace)
|
||
{
|
||
// Normaler Modus mit Workspace: alle File-Tools
|
||
tools.AddRange(new[] { "Bash(*)", "Read", "Write", "Edit", "Glob", "Grep" });
|
||
}
|
||
else
|
||
{
|
||
// Kein Workspace: Read für Temp-Dateien (z.B. gesendete Bilder)
|
||
tools.Add("Read");
|
||
}
|
||
if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); }
|
||
// MCP-Tools erlauben (wenn MCP-Config vorhanden)
|
||
if (!string.IsNullOrEmpty(mcpConfigPath))
|
||
{
|
||
tools.Add("mcp__gott-remote__remote_execute");
|
||
tools.Add("mcp__gott-remote__ask_user_choice");
|
||
tools.Add("mcp__gott-remote__memory_read");
|
||
tools.Add("mcp__gott-remote__memory_write");
|
||
tools.Add("mcp__gott-remote__memory_list");
|
||
}
|
||
// Unity MCP Tools erlauben (alle Tools vom unity MCP-Server)
|
||
if (hasUnityMcp)
|
||
{
|
||
tools.Add("mcp__unity__*");
|
||
}
|
||
args.Add("--allowed-tools");
|
||
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
|
||
|
||
if (!string.IsNullOrEmpty(workspace))
|
||
{
|
||
args.Add("--add-dir");
|
||
args.Add(workspace);
|
||
}
|
||
|
||
// Temp-Ordner immer zugänglich machen (für gesendete Bilder etc.)
|
||
var tempImageDir = Path.Combine(Path.GetTempPath(), "gott-cli-images");
|
||
if (Directory.Exists(tempImageDir))
|
||
{
|
||
args.Add("--add-dir");
|
||
args.Add(tempImageDir);
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(modelId))
|
||
{
|
||
args.Add("--model");
|
||
args.Add(modelId);
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(systemPrompt))
|
||
{
|
||
args.Add("--system-prompt");
|
||
args.Add(systemPrompt);
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(mcpConfigPath))
|
||
{
|
||
args.Add("--mcp-config");
|
||
args.Add(mcpConfigPath);
|
||
}
|
||
|
||
return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken, effort);
|
||
}
|
||
|
||
private async Task<CliChatResult> RunCliStreamingAsync(
|
||
List<string> args,
|
||
string stdinPrompt,
|
||
Func<CliStreamEvent, Task> onEvent,
|
||
CancellationToken cancellationToken,
|
||
string effort = null)
|
||
{
|
||
var result = new CliChatResult();
|
||
var fullOutput = new StringBuilder();
|
||
var error = new StringBuilder();
|
||
|
||
// Tracking für content blocks
|
||
string currentBlockType = null; // "text" oder "tool_use"
|
||
string currentToolName = null;
|
||
string currentToolId = null;
|
||
var currentToolInput = new StringBuilder();
|
||
bool inTextBlock = false;
|
||
|
||
var lineBuffer = new StringBuilder();
|
||
|
||
// Encoding-Debug Cleanup-Holder (im finally geschlossen)
|
||
StreamWriter debugMarkerWriter = null;
|
||
|
||
try
|
||
{
|
||
var startInfo = CreateCliStartInfo(args, redirectInput: true, effort: effort);
|
||
|
||
using var process = new Process { StartInfo = startInfo };
|
||
|
||
process.ErrorDataReceived += (s, e) =>
|
||
{
|
||
if (e.Data != null)
|
||
{
|
||
error.AppendLine(e.Data);
|
||
_logger.LogDebug("CLI stderr: {Line}", e.Data);
|
||
}
|
||
};
|
||
|
||
process.Start();
|
||
process.BeginErrorReadLine();
|
||
|
||
// Bei Cancellation den gesamten Prozessbaum beenden
|
||
// (ReadLineAsync wirft OperationCanceledException, aber process.Dispose() killt NICHT den Prozess)
|
||
using var killOnCancel = cancellationToken.Register(() =>
|
||
{
|
||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { }
|
||
});
|
||
|
||
await process.StandardInput.WriteAsync(stdinPrompt);
|
||
await process.StandardInput.FlushAsync();
|
||
process.StandardInput.Close();
|
||
|
||
// === ENCODING-DIAGNOSE === (nur aktiv wenn GOTT_CLI_DEBUG_BYTES=1)
|
||
// Wenn aktiviert, alle rohen Bytes in eine .bin-Datei teen und Marker-Log
|
||
// für jedes Vorkommen von U+FFFD schreiben. So können wir feststellen, ob das
|
||
// Replacement-Char schon vom Claude-CLI kommt (.bin enthält bereits invalide
|
||
// UTF-8-Bytes) oder erst beim Decode entsteht (.bin ist sauber, aber decoded
|
||
// line hat �). Im Default-Modus (env nicht gesetzt) bleibt der ursprüngliche
|
||
// Default-Reader aktiv – kein zusätzlicher Disk-IO im Normalbetrieb.
|
||
bool debugBytes = IsByteDebugEnabled();
|
||
FileStream rawStream = null;
|
||
StreamWriter markerWriter = null;
|
||
string debugSessionId = null;
|
||
|
||
if (debugBytes)
|
||
{
|
||
try
|
||
{
|
||
debugSessionId = DateTime.Now.ToString("yyyyMMdd-HHmmss-fff");
|
||
var debugDir = GetDebugDir();
|
||
var rawPath = Path.Combine(debugDir, $"cli-stdout-{debugSessionId}.bin");
|
||
var markerPath = Path.Combine(debugDir, $"cli-stdout-{debugSessionId}.log");
|
||
rawStream = new FileStream(rawPath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||
markerWriter = new StreamWriter(markerPath, append: false, new UTF8Encoding(false)) { AutoFlush = true };
|
||
debugMarkerWriter = markerWriter; // für finally-Cleanup
|
||
markerWriter.WriteLine($"# CLI Encoding Debug Log – Session {debugSessionId}");
|
||
markerWriter.WriteLine($"# Roh-Bytes: {Path.GetFileName(rawPath)}");
|
||
markerWriter.WriteLine($"# Args: {string.Join(" ", args)}");
|
||
markerWriter.WriteLine($"# OS: {RuntimeInformation.OSDescription}, ACP={Encoding.Default.CodePage}, OEM={Console.OutputEncoding.CodePage}");
|
||
markerWriter.WriteLine();
|
||
_logger.LogWarning("CLI encoding debug aktiv. Roh-Bytes → {Raw}, Marker-Log → {Log}", rawPath, markerPath);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Konnte CLI-Debug-Logging nicht initialisieren – fahre ohne fort");
|
||
debugBytes = false;
|
||
rawStream?.Dispose(); rawStream = null;
|
||
markerWriter?.Dispose(); markerWriter = null;
|
||
}
|
||
}
|
||
|
||
// Im Debug-Modus: Custom-Reader mit TeeStream (16 KB Buffer + UTF-8 ohne BOM-Detection).
|
||
// Im Default-Modus: ursprünglicher Reader (= process.StandardOutput) – kein Encoding-
|
||
// override, weil die diversen Härtungsversuche (chcp, UTF-8-Reader, Console.OutputEncoding)
|
||
// das �-Problem nachweislich nicht behoben haben. Wir warten auf das Diagnose-Ergebnis,
|
||
// bevor wir den nächsten Fix-Versuch starten.
|
||
using StreamReader reader = (debugBytes && rawStream != null)
|
||
? new StreamReader(
|
||
new TeeReadStream(process.StandardOutput.BaseStream, rawStream, leaveTeeOpen: false),
|
||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||
detectEncodingFromByteOrderMarks: false,
|
||
bufferSize: 16384,
|
||
leaveOpen: false)
|
||
: process.StandardOutput;
|
||
string line;
|
||
int lineNum = 0;
|
||
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
|
||
{
|
||
lineNum++;
|
||
|
||
// Marker-Log: U+FFFD im decoded Output erkennen
|
||
if (debugBytes && markerWriter != null && line.IndexOf('�') >= 0)
|
||
{
|
||
try
|
||
{
|
||
var firstAt = line.IndexOf('�');
|
||
int ctxStart = Math.Max(0, firstAt - 40);
|
||
int ctxEnd = Math.Min(line.Length, firstAt + 40);
|
||
var ctx = line.Substring(ctxStart, ctxEnd - ctxStart);
|
||
// U+FFFD im Kontext durch sichtbares Marker ersetzen für die Log-Zeile
|
||
var ctxVis = ctx.Replace("�", "<<U+FFFD>>");
|
||
markerWriter.WriteLine($"[Line {lineNum}] FFFD count={CountChar(line, '�')} firstPos={firstAt} totalLen={line.Length}");
|
||
markerWriter.WriteLine($" Context: ...{ctxVis}...");
|
||
markerWriter.WriteLine($" Full line (first 500 chars): {(line.Length > 500 ? line.Substring(0, 500) : line).Replace("�", "<<U+FFFD>>")}");
|
||
markerWriter.WriteLine();
|
||
_logger.LogWarning("CLI U+FFFD detected on line {Ln} at pos {Pos}: ...{Ctx}...", lineNum, firstAt, ctxVis);
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
if (!line.StartsWith("{"))
|
||
continue;
|
||
|
||
try
|
||
{
|
||
var json = JsonSerializer.Deserialize<JsonElement>(line);
|
||
var type = json.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||
|
||
if (type == "stream_event" && json.TryGetProperty("event", out var evt))
|
||
{
|
||
var evtType = evt.TryGetProperty("type", out var et) ? et.GetString() : null;
|
||
|
||
switch (evtType)
|
||
{
|
||
case "content_block_start":
|
||
{
|
||
if (evt.TryGetProperty("content_block", out var cb))
|
||
{
|
||
var cbType = cb.TryGetProperty("type", out var cbt) ? cbt.GetString() : null;
|
||
|
||
if (cbType == "text")
|
||
{
|
||
currentBlockType = "text";
|
||
if (!inTextBlock)
|
||
{
|
||
inTextBlock = true;
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextStart });
|
||
}
|
||
}
|
||
else if (cbType == "tool_use")
|
||
{
|
||
// Text-Block vorher abschließen + lineBuffer flushen
|
||
if (inTextBlock)
|
||
{
|
||
// Restlichen Text im lineBuffer emittieren bevor Tool startet
|
||
if (lineBuffer.Length > 0)
|
||
{
|
||
var remaining = lineBuffer.ToString();
|
||
lineBuffer.Clear();
|
||
if (remaining.Trim().Length > 0)
|
||
{
|
||
fullOutput.Append(remaining);
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
||
}
|
||
}
|
||
inTextBlock = false;
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd });
|
||
}
|
||
|
||
currentBlockType = "tool_use";
|
||
currentToolName = cb.TryGetProperty("name", out var n) ? n.GetString() : "unknown";
|
||
currentToolId = cb.TryGetProperty("id", out var id) ? id.GetString() : "";
|
||
currentToolInput.Clear();
|
||
|
||
await onEvent(new CliStreamEvent
|
||
{
|
||
Type = CliEventType.ToolStart,
|
||
ToolName = currentToolName,
|
||
ToolId = currentToolId
|
||
});
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case "content_block_delta":
|
||
{
|
||
if (evt.TryGetProperty("delta", out var delta))
|
||
{
|
||
var deltaType = delta.TryGetProperty("type", out var dt) ? dt.GetString() : null;
|
||
|
||
if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt))
|
||
{
|
||
var text = txt.GetString();
|
||
if (!string.IsNullOrEmpty(text))
|
||
{
|
||
for (int ci = 0; ci < text.Length; ci++)
|
||
{
|
||
var ch = text[ci];
|
||
if (ch == '\n')
|
||
{
|
||
var completedLine = lineBuffer.ToString().Trim();
|
||
lineBuffer.Clear();
|
||
|
||
if (completedLine.Length > 0 || fullOutput.Length > 0)
|
||
{
|
||
var lineText = completedLine + "\n";
|
||
fullOutput.Append(lineText);
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText });
|
||
}
|
||
}
|
||
else
|
||
{
|
||
lineBuffer.Append(ch);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else if (deltaType == "input_json_delta" && delta.TryGetProperty("partial_json", out var pj))
|
||
{
|
||
currentToolInput.Append(pj.GetString());
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case "content_block_stop":
|
||
{
|
||
if (currentBlockType == "tool_use")
|
||
{
|
||
await onEvent(new CliStreamEvent
|
||
{
|
||
Type = CliEventType.ToolEnd,
|
||
ToolName = currentToolName,
|
||
ToolId = currentToolId,
|
||
ToolInput = currentToolInput.ToString()
|
||
});
|
||
currentBlockType = null;
|
||
currentToolName = null;
|
||
currentToolId = null;
|
||
currentToolInput.Clear();
|
||
}
|
||
else if (currentBlockType == "text")
|
||
{
|
||
// lineBuffer flushen (Text ohne trailing \n)
|
||
if (lineBuffer.Length > 0)
|
||
{
|
||
var remaining = lineBuffer.ToString();
|
||
lineBuffer.Clear();
|
||
if (remaining.Trim().Length > 0)
|
||
{
|
||
fullOutput.Append(remaining);
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
||
}
|
||
}
|
||
currentBlockType = null;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
else if (type == "result")
|
||
{
|
||
// Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile)
|
||
if (lineBuffer.Length > 0)
|
||
{
|
||
var remaining = lineBuffer.ToString().Trim();
|
||
lineBuffer.Clear();
|
||
|
||
if (remaining.Length > 0)
|
||
{
|
||
fullOutput.Append(remaining);
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
||
}
|
||
}
|
||
|
||
// Offenen Text-Block abschließen
|
||
if (inTextBlock)
|
||
{
|
||
inTextBlock = false;
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd });
|
||
}
|
||
|
||
if (json.TryGetProperty("usage", out var usage))
|
||
{
|
||
result.InputTokens = usage.TryGetProperty("input_tokens", out var it) ? it.GetInt64() : 0;
|
||
result.OutputTokens = usage.TryGetProperty("output_tokens", out var ot) ? ot.GetInt64() : 0;
|
||
result.CacheReadTokens = usage.TryGetProperty("cache_read_input_tokens", out var cr) ? cr.GetInt64() : 0;
|
||
result.CacheCreationTokens = usage.TryGetProperty("cache_creation_input_tokens", out var cc) ? cc.GetInt64() : 0;
|
||
}
|
||
if (json.TryGetProperty("total_cost_usd", out var cost))
|
||
result.CostUsd = cost.GetDouble();
|
||
if (json.TryGetProperty("duration_api_ms", out var dur))
|
||
result.DurationApiMs = dur.GetInt64();
|
||
if (json.TryGetProperty("session_id", out var sid))
|
||
result.SessionId = sid.GetString();
|
||
if (json.TryGetProperty("num_turns", out var nt))
|
||
result.NumTurns = nt.GetInt32();
|
||
|
||
var isError = json.TryGetProperty("is_error", out var ie) && ie.GetBoolean();
|
||
if (isError)
|
||
result.Error = json.TryGetProperty("result", out var errMsg) ? errMsg.GetString() : "CLI error";
|
||
}
|
||
else if (type == "user" && json.TryGetProperty("message", out var userMsg))
|
||
{
|
||
// CLI sendet tool_result als type:"user" message
|
||
if (userMsg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in content.EnumerateArray())
|
||
{
|
||
var itemType = item.TryGetProperty("type", out var iType) ? iType.GetString() : null;
|
||
if (itemType == "tool_result")
|
||
{
|
||
var toolUseId = item.TryGetProperty("tool_use_id", out var tuId) ? tuId.GetString() : "";
|
||
var resultIsError = item.TryGetProperty("is_error", out var isErr) && isErr.GetBoolean();
|
||
|
||
// Content kann string oder array sein
|
||
var resultContent = "";
|
||
if (item.TryGetProperty("content", out var rc))
|
||
{
|
||
if (rc.ValueKind == JsonValueKind.String)
|
||
resultContent = rc.GetString();
|
||
else if (rc.ValueKind == JsonValueKind.Array)
|
||
{
|
||
var sb = new StringBuilder();
|
||
foreach (var part in rc.EnumerateArray())
|
||
{
|
||
if (part.TryGetProperty("text", out var partText))
|
||
sb.Append(partText.GetString());
|
||
}
|
||
resultContent = sb.ToString();
|
||
}
|
||
}
|
||
|
||
await onEvent(new CliStreamEvent
|
||
{
|
||
Type = CliEventType.ToolResult,
|
||
ToolId = toolUseId,
|
||
ToolResultContent = resultContent,
|
||
ToolResultIsError = resultIsError
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
_logger.LogDebug("CLI JSON parse error: {Error} for line: {Line}", ex.Message, line.Length > 200 ? line[..200] : line);
|
||
}
|
||
}
|
||
|
||
// Restlichen lineBuffer flushen
|
||
if (lineBuffer.Length > 0)
|
||
{
|
||
var remaining = lineBuffer.ToString().Trim();
|
||
lineBuffer.Clear();
|
||
|
||
if (remaining.Length > 0)
|
||
{
|
||
fullOutput.Append(remaining);
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
||
}
|
||
}
|
||
|
||
// Sicherstellen, dass offene Blöcke geschlossen werden
|
||
if (currentBlockType == "tool_use" && currentToolName != null)
|
||
{
|
||
await onEvent(new CliStreamEvent
|
||
{
|
||
Type = CliEventType.ToolEnd,
|
||
ToolName = currentToolName,
|
||
ToolId = currentToolId,
|
||
ToolInput = currentToolInput.ToString()
|
||
});
|
||
currentBlockType = null;
|
||
}
|
||
if (inTextBlock)
|
||
await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd });
|
||
|
||
await process.WaitForExitAsync(cancellationToken);
|
||
|
||
result.ExitCode = process.ExitCode;
|
||
result.Output = fullOutput.ToString().Trim();
|
||
result.Success = process.ExitCode == 0 && fullOutput.Length > 0;
|
||
|
||
if (!result.Success && string.IsNullOrEmpty(result.Error))
|
||
result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}";
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
result.Success = false;
|
||
result.Error = "Abgebrochen";
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
result.Success = false;
|
||
result.Error = ex.Message;
|
||
_logger.LogError(ex, "Fehler beim Streaming der Claude CLI");
|
||
}
|
||
finally
|
||
{
|
||
// Encoding-Debug Marker-Writer abschließen (rawStream wird vom TeeReadStream-Dispose geschlossen)
|
||
if (debugMarkerWriter != null)
|
||
{
|
||
try
|
||
{
|
||
debugMarkerWriter.WriteLine();
|
||
debugMarkerWriter.WriteLine($"# End. fullOutput length = {fullOutput.Length}, FFFD count = {CountChar(fullOutput.ToString(), '�')}");
|
||
debugMarkerWriter.Flush();
|
||
debugMarkerWriter.Dispose();
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static int CountChar(string s, char c)
|
||
{
|
||
if (string.IsNullOrEmpty(s)) return 0;
|
||
int n = 0;
|
||
for (int i = 0; i < s.Length; i++) if (s[i] == c) n++;
|
||
return n;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prüft ob die CLI funktioniert und authentifiziert ist.
|
||
/// </summary>
|
||
public async Task<CliStatusResult> GetStatusAsync()
|
||
{
|
||
var result = new CliStatusResult { IsAvailable = _isAvailable };
|
||
|
||
if (!_isAvailable)
|
||
{
|
||
result.Error = "Claude CLI nicht gefunden";
|
||
return result;
|
||
}
|
||
|
||
try
|
||
{
|
||
var versionResult = await RunSimpleAsync(["--version"], CancellationToken.None);
|
||
if (versionResult.Success)
|
||
result.Version = versionResult.Output.Trim();
|
||
|
||
var doctorResult = await RunSimpleAsync(["doctor"], CancellationToken.None);
|
||
result.IsAuthenticated = doctorResult.Success;
|
||
|
||
if (!doctorResult.Success)
|
||
result.Error = doctorResult.Error ?? doctorResult.Output;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
result.Error = ex.Message;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private async Task<CliChatResult> RunSimpleAsync(List<string> args, CancellationToken cancellationToken)
|
||
{
|
||
var result = new CliChatResult();
|
||
var output = new StringBuilder();
|
||
var error = new StringBuilder();
|
||
|
||
try
|
||
{
|
||
var startInfo = CreateCliStartInfo(args);
|
||
|
||
using var process = new Process { StartInfo = startInfo };
|
||
process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); };
|
||
process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); };
|
||
|
||
process.Start();
|
||
process.BeginOutputReadLine();
|
||
process.BeginErrorReadLine();
|
||
|
||
using var killOnCancel = cancellationToken.Register(() =>
|
||
{
|
||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { }
|
||
});
|
||
|
||
await process.WaitForExitAsync(cancellationToken);
|
||
|
||
result.ExitCode = process.ExitCode;
|
||
result.Output = output.ToString().Trim();
|
||
result.Success = process.ExitCode == 0;
|
||
|
||
if (!result.Success)
|
||
result.Error = error.Length > 0 ? error.ToString().Trim() : $"Exit code: {process.ExitCode}";
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
result.Success = false;
|
||
result.Error = "Abgebrochen";
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
result.Success = false;
|
||
result.Error = ex.Message;
|
||
_logger.LogError(ex, "Fehler beim Ausführen der Claude CLI");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stream-Wrapper, der jedes gelesene Byte parallel in einen zweiten Stream (Tee-File) schreibt.
|
||
/// Wird für Encoding-Diagnose verwendet, um die rohen Bytes vom Claude-CLI-Prozess zu konservieren,
|
||
/// bevor sie durch den UTF-8-StreamReader gehen.
|
||
/// </summary>
|
||
internal sealed class TeeReadStream : Stream
|
||
{
|
||
private readonly Stream _source;
|
||
private readonly Stream _tee;
|
||
private readonly bool _leaveTeeOpen;
|
||
private long _totalRead;
|
||
|
||
public TeeReadStream(Stream source, Stream tee, bool leaveTeeOpen = false)
|
||
{
|
||
_source = source ?? throw new ArgumentNullException(nameof(source));
|
||
_tee = tee ?? throw new ArgumentNullException(nameof(tee));
|
||
_leaveTeeOpen = leaveTeeOpen;
|
||
}
|
||
|
||
public long TotalRead => _totalRead;
|
||
|
||
public override bool CanRead => _source.CanRead;
|
||
public override bool CanSeek => false;
|
||
public override bool CanWrite => false;
|
||
public override long Length => throw new NotSupportedException();
|
||
public override long Position { get => _totalRead; set => throw new NotSupportedException(); }
|
||
|
||
public override int Read(byte[] buffer, int offset, int count)
|
||
{
|
||
var n = _source.Read(buffer, offset, count);
|
||
if (n > 0)
|
||
{
|
||
_tee.Write(buffer, offset, n);
|
||
_tee.Flush();
|
||
_totalRead += n;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||
{
|
||
var n = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||
if (n > 0)
|
||
{
|
||
await _tee.WriteAsync(buffer.Slice(0, n), cancellationToken).ConfigureAwait(false);
|
||
await _tee.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||
_totalRead += n;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||
{
|
||
var n = await _source.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false);
|
||
if (n > 0)
|
||
{
|
||
await _tee.WriteAsync(buffer.AsMemory(offset, n), cancellationToken).ConfigureAwait(false);
|
||
await _tee.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||
_totalRead += n;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
public override void Flush() { /* read-only */ }
|
||
|
||
public override int Read(Span<byte> buffer)
|
||
{
|
||
var n = _source.Read(buffer);
|
||
if (n > 0)
|
||
{
|
||
_tee.Write(buffer.Slice(0, n));
|
||
_tee.Flush();
|
||
_totalRead += n;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||
public override void SetLength(long value) => throw new NotSupportedException();
|
||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||
|
||
protected override void Dispose(bool disposing)
|
||
{
|
||
if (disposing)
|
||
{
|
||
try { _source.Dispose(); } catch { }
|
||
try
|
||
{
|
||
if (!_leaveTeeOpen) _tee.Dispose();
|
||
else _tee.Flush();
|
||
}
|
||
catch { }
|
||
}
|
||
base.Dispose(disposing);
|
||
}
|
||
}
|
||
|
||
public enum CliEventType
|
||
{
|
||
TextStart,
|
||
TextDelta,
|
||
TextEnd,
|
||
ToolStart,
|
||
ToolEnd,
|
||
ToolResult
|
||
}
|
||
|
||
public class CliStreamEvent
|
||
{
|
||
public CliEventType Type { get; set; }
|
||
public string Text { get; set; }
|
||
public string ToolName { get; set; }
|
||
public string ToolId { get; set; }
|
||
public string ToolInput { get; set; }
|
||
public string ToolResultContent { get; set; }
|
||
public bool ToolResultIsError { get; set; }
|
||
}
|
||
|
||
public class CliChatResult
|
||
{
|
||
public bool Success { get; set; }
|
||
public string Output { get; set; }
|
||
public string Error { get; set; }
|
||
public int ExitCode { get; set; }
|
||
public long InputTokens { get; set; }
|
||
public long OutputTokens { get; set; }
|
||
public long CacheReadTokens { get; set; }
|
||
public long CacheCreationTokens { get; set; }
|
||
public double CostUsd { get; set; }
|
||
public long DurationApiMs { get; set; }
|
||
public string SessionId { get; set; }
|
||
public int NumTurns { get; set; }
|
||
}
|
||
|
||
public class CliStatusResult
|
||
{
|
||
public bool IsAvailable { get; set; }
|
||
public bool IsAuthenticated { get; set; }
|
||
public string Version { get; set; }
|
||
public string Error { get; set; }
|
||
}
|