Files
gott/Services/AnthropicService.cs
T
MelekiandClaude Opus 4.8 2b95161596 Read workflow-status telemetry from CLI transcript files
Dynamic ultracode workflows run as a detached background task with their
own transcript dir, so their subagent events never flow through the main
-p stream. The previous parent_tool_use_id tap therefore captured nothing
for real workflows and the status stayed "none" the whole run.

Replace the tap with a file-based reader:
- TapWorkflowLaunch parses the "Transcript dir:" line from the Workflow
  tool_result and registers the run.
- WorkflowProgressTracker becomes a per-session registry of transcript
  paths (no longer a stream accumulator).
- WorkflowTelemetryReader reads journal.jsonl for live counts (running)
  and <runId>.json for rich per-agent telemetry (completed); FinalizeChain
  persists the rich JSON into chains.workflow_info.
- Modal renders phases + a per-agent list (label/phase/state/tokens/
  tool-calls/duration) instead of the obsolete tool breakdown.

Sync ARCHITECTURE.md, ARCHITECTURE_QUICK.md and README.md to the new
mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:14:42 +02:00

475 lines
20 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.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.Endpoints;
using Gott.Models;
using Microsoft.AspNetCore.Http;
namespace Gott.Services;
public partial class AnthropicService
{
/// <summary>
/// Optionen für AddToolsToPayload – ersetzt die 12 bool-Parameter.
/// </summary>
public record ToolPayloadOptions
{
public string Workspace { get; init; }
public string AgentId { get; init; }
public bool UseMemory { get; init; }
public string WebSearchVersion { get; init; }
public string ToolChoice { get; init; }
public int Iteration { get; init; }
public bool AllowUserChoice { get; init; }
public bool PlanMode { get; init; }
public bool IncludeRemote { get; init; }
public string RemoteOs { get; init; }
public bool IncludeGitHubSearch { get; init; }
public bool IncludeSerperSearch { get; init; }
}
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 readonly SteeringQueue _steeringQueue;
private readonly WorkflowProgressTracker _workflowTracker;
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, SteeringQueue steeringQueue = null, WorkflowProgressTracker workflowTracker = null)
{
_httpFactory = httpFactory;
_toolService = toolService;
_db = db;
_memoryService = memoryService;
_settings = settings;
_vectorSearch = vectorSearch;
_embeddingBg = embeddingBg;
_steeringQueue = steeringQueue;
_workflowTracker = workflowTracker;
}
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 */ }
}
// ── 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 die letzten N User-Texte für Query-Erweiterung.</summary>
internal static string ExtractRecentUserTexts(List<object> messages, int count = 3, int maxLength = 500)
{
var texts = new List<string>();
for (int i = messages.Count - 1; i >= 0 && texts.Count < count; 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;
string text = null;
if (content.ValueKind == JsonValueKind.String)
text = content.GetString();
else 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))
{ text = txt.GetString(); break; }
}
}
if (!string.IsNullOrWhiteSpace(text))
texts.Add(text);
}
texts.Reverse(); // chronological order
var combined = string.Join(" ", texts);
return combined.Length > maxLength ? combined.Substring(0, maxLength) : combined;
}
/// <summary>
/// Baut den AutoContext-String aus Embedding-Suche.
/// Shared zwischen StreamChat, StreamChatViaCli und OpenAiService.
/// </summary>
internal static async Task<string> BuildAutoContextAsync(VectorSearchService vectorSearch, List<object> messages, string chainId, string agentId)
{
if (vectorSearch == null || messages.Count == 0) return null;
// Query-Erweiterung: letzte 2-3 User-Messages statt nur die letzte
var queryText = ExtractRecentUserTexts(messages, count: 3, maxLength: 500);
if (string.IsNullOrWhiteSpace(queryText)) return null;
var contextResults = await vectorSearch.SearchAsync(queryText, agentId, topN: 5, minScore: 0.35f);
// Note: threshold lowered from 0.45 to 0.35 because hybrid search is now more precise
contextResults.RemoveAll(r => r.ChainId == chainId || (r.TextPreview?.Length ?? 0) < 20);
var memoryResults = await vectorSearch.SearchMemoryFilesAsync(queryText, agentId, topN: 3, minScore: 0.35f);
if (contextResults.Count == 0 && memoryResults.Count == 0) return null;
var ctxSb = new StringBuilder();
ctxSb.AppendLine("\n<recalled_context>");
ctxSb.AppendLine("WICHTIG: Der folgende Kontext wurde automatisch per Vektor-Suche aus früheren Gesprächen und Memory abgerufen. Prüfe diesen Kontext ZUERST, bevor du antwortest. Wenn er zur aktuellen Frage passt, beziehe dich aktiv darauf und nutze die Informationen in deiner Antwort. Ignoriere ihn nur, wenn er eindeutig irrelevant ist.");
foreach (var r in contextResults)
{
var date = DateTime.TryParse(r.CreatedAt, out var dt) ? dt.ToString("yyyy-MM-dd HH:mm") : r.CreatedAt;
// Use FullText (up to 800 chars) if available, otherwise TextPreview
var displayText = r.FullText ?? r.TextPreview ?? "";
if (displayText.Length > 800) displayText = displayText.Substring(0, 800);
ctxSb.AppendLine($"- [Gespräch, {r.Role}, {date}]: {displayText}");
}
foreach (var r in memoryResults)
{
var displayText = r.FullText ?? r.TextPreview ?? "";
if (displayText.Length > 800) displayText = displayText.Substring(0, 800);
ctxSb.AppendLine($"- [Memory, {r.FilePath}]: {displayText}");
}
ctxSb.AppendLine("</recalled_context>");
return ctxSb.ToString();
}
/// <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) continue;
var refs = new List<string>();
var tempDir = Path.Combine(Path.GetTempPath(), "gott-cli-images");
Directory.CreateDirectory(tempDir);
foreach (var block in content.EnumerateArray())
{
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
// "document" = Frontend-Format für PDFs (chat.js sendet type:'document' für non-image files).
// "file" = Legacy-Alias für Datei-Anhänge.
if (blockType != "image" && blockType != "file" && blockType != "document") continue;
var source = block.TryGetProperty("source", out var src) ? src : default;
if (source.ValueKind == JsonValueKind.Undefined) continue;
var sourceType = source.TryGetProperty("type", out var st) ? st.GetString() ?? "" : "";
if (sourceType != "base64") continue;
var data = source.TryGetProperty("data", out var d) ? d.GetString() ?? "" : "";
var mediaType = source.TryGetProperty("media_type", out var mt) ? mt.GetString() ?? "" : "";
var ext = mediaType switch
{
"image/png" => ".png",
"image/jpeg" => ".jpg",
"image/gif" => ".gif",
"image/webp" => ".webp",
"application/pdf" => ".pdf",
_ => ".bin"
};
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 { }
}
}
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)
{
// Workflow-Telemetrie (ultracode-Subagenten) für DIESE Chain persistieren und den
// Live-Tracker freigeben. Der ChainId-Abgleich verhindert, dass ein anderer Finalize-Pfad
// derselben Session (z.B. ein Panel-Member) den Workflow-Eintrag fälschlich abräumt.
if (_workflowTracker != null && !string.IsNullOrEmpty(chain.SessionId)
&& _workflowTracker.ChainIdFor(chain.SessionId) == chain.ChainId)
{
try
{
// Reiche End-Telemetrie aus der wf_<runId>.json des Laufs lesen und persistieren
// (Fallback: Live-Counts aus dem journal.jsonl, falls die json noch nicht da ist).
var wp = WorkflowTelemetryReader.Read(_workflowTracker.Get(chain.SessionId));
if (wp != null)
{
wp.ChainId = chain.ChainId;
chain.WorkflowInfo = JsonSerializer.Serialize(wp);
}
}
catch { /* Persistenz der Telemetrie darf den Finalize nie crashen */ }
_workflowTracker.End(chain.SessionId);
}
return SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo);
}
/// <summary>
/// Versieht das letzte Element einer Block-Liste (Tools, System-Prompt-Blöcke, etc.) mit
/// einer cache_control-Annotation. Konvertiert das Element über JSON-Roundtrip in ein
/// Dictionary, damit cache_control auch bei anonymous types gesetzt werden kann.
/// Idempotent: Mehrfaches Aufrufen überschreibt nur das cache_control-Feld.
/// </summary>
private static void ApplyCacheControlToLast(List<object> blocks, string ttl = "1h")
{
if (blocks == null || blocks.Count == 0) return;
var last = blocks[blocks.Count - 1];
Dictionary<string, object> dict;
if (last is Dictionary<string, object> existing)
{
dict = existing;
}
else
{
var json = JsonSerializer.Serialize(last);
var parsed = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
dict = parsed ?? new Dictionary<string, object>();
}
dict["cache_control"] = new { type = "ephemeral", ttl };
blocks[blocks.Count - 1] = dict;
}
/// <summary>
/// Fügt Tool-Definitionen und WebSearch zum Payload hinzu.
/// </summary>
private void AddToolsToPayload(Dictionary<string, object> payload, ToolPayloadOptions opts)
{
var hasWorkspace = !string.IsNullOrEmpty(opts.Workspace);
var includeMemory = opts.UseMemory && _memoryService != null && !string.IsNullOrEmpty(opts.AgentId);
var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0;
var includeSendToSession = _db != null && _chatService != null;
var hasTools = hasWorkspace || includeMemory || opts.IncludeRemote || includeMemorySearch || includeSendToSession || opts.IncludeGitHubSearch || opts.IncludeSerperSearch;
var hasWebSearch = !string.IsNullOrEmpty(opts.WebSearchVersion);
if (!hasTools && !hasWebSearch) return;
var tools = new List<object>();
if (hasTools)
{
var allTools = _toolService.GetToolDefinitions(new ToolDefinitionOptions
{
IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory,
IncludeUserChoice = opts.AllowUserChoice, IncludeRemote = opts.IncludeRemote, RemoteOs = opts.RemoteOs,
IncludeMemorySearch = includeMemorySearch, IncludeSendToSession = includeSendToSession,
IncludeGitHubSearch = opts.IncludeGitHubSearch, IncludeSerperSearch = opts.IncludeSerperSearch
});
if (opts.PlanMode)
allTools = _toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools);
}
if (hasWebSearch)
{
tools.Add(new { type = opts.WebSearchVersion, name = "web_search", max_uses = 5 });
// web_search_20260209: Code Execution wird von der API automatisch injected – NICHT manuell hinzufügen!
}
// Cache-Breakpoint auf das letzte Tool → cached den gesamten Tool-Block (1h TTL).
// Da Tools pro Iteration neu generiert werden, ist das idempotent.
ApplyCacheControlToLast(tools);
payload["tools"] = tools;
var effectiveChoice = (!opts.PlanMode && opts.ToolChoice == "any" && opts.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,extended-cache-ttl-2025-04-11");
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
}