diff --git a/.gitignore b/.gitignore index 9424cb6..490b87e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ MongoDB/ # Gitea Personal Access Token für Pushes – niemals committen .gitea-token master.key +openrouter.key # ── Logs & temporäre Dateien ───────────────────── *.log diff --git a/docs/sicherheit/SICHERHEITSKONZEPT.md b/docs/sicherheit/SICHERHEITSKONZEPT.md index 4329f33..7c2531d 100644 --- a/docs/sicherheit/SICHERHEITSKONZEPT.md +++ b/docs/sicherheit/SICHERHEITSKONZEPT.md @@ -45,10 +45,15 @@ gefunden. Ausgehende Verbindungen (Code-Audit der URLs): gebroadcastete Transaktionen. - **Eigene MySQL-DB** (remote gehostet) — eigene Infrastruktur, kein Dritt-Dienst im engeren Sinn. - **Mullvad VPN** — bewusstes Datenschutz-Tool (Routing). -- **Threema Gateway** (`msgapi.threema.ch`) — **einziger Nicht-Polymarket-Dienst, der App-Inhalte - empfängt** (unsere Benachrichtigungstexte). Bewusstes Feature; end-to-end-verschlüsselt. -→ **Keine versteckte Datenweitergabe.** OpenRouter/AI-Anbindung existiert nur im separaten -Predictalytics-Projekt, **nicht** in PolyTrader. +- **Threema Gateway** (`msgapi.threema.ch`) — App-Inhalte: unsere Benachrichtigungstexte. + Bewusstes Feature; end-to-end-verschlüsselt. +- **OpenRouter** (`openrouter.ai`, seit S-2 Supervisor-Modul) — **bewusst freigegebener** externer + Datenempfänger für die KI-Analyse: Es werden ausschließlich die Ergebnisse der read-only-Analyse-Tools + gesendet (Entscheidungsjournal, Order-Events, Trades, Log-Auszüge, KPIs, Architektur-Doku) — **niemals + Secrets/Keys** (Logs sind per F6 secret-frei). API-Key getrennt vom Trading + (env `POLYTRADER_OPENROUTER_KEY` bzw. gitignorierte `openrouter.key`); Spend-Limit je Key bei + OpenRouter setzen. Der Agent ist strikt read-only (keine Handels-Tools). +→ **Keine versteckte Datenweitergabe** — jede neue ausgehende Verbindung wird hier dokumentiert. --- diff --git a/src/PolyTrader.Modules.Supervisor/Agent/ArchitectureContext.cs b/src/PolyTrader.Modules.Supervisor/Agent/ArchitectureContext.cs new file mode 100644 index 0000000..e09eb7f --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Agent/ArchitectureContext.cs @@ -0,0 +1,23 @@ +using System.IO; +using System.Reflection; + +namespace PolyTrader.Modules.Supervisor.Agent +{ + /// + /// Lädt das kuratierte Architektur-/Verhaltensdokument (Context/ArchitectureContext.md, + /// als EmbeddedResource versioniert mit dem Code deployt). Es ist der System-Kontext des + /// Agenten — bei Änderungen am Geld-Pfad mitpflegen. + /// + public static class ArchitectureContext + { + public static string Load() + { + var asm = Assembly.GetExecutingAssembly(); + const string resource = "PolyTrader.Modules.Supervisor.Context.ArchitectureContext.md"; + using var stream = asm.GetManifestResourceStream(resource); + if (stream == null) return "(Architektur-Kontext-Ressource nicht gefunden)"; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Agent/ChatModels.cs b/src/PolyTrader.Modules.Supervisor/Agent/ChatModels.cs new file mode 100644 index 0000000..2e1443f --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Agent/ChatModels.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace PolyTrader.Modules.Supervisor.Agent +{ + /// Chat-Nachricht im OpenAI-/OpenRouter-Schema (Rollen: system/user/assistant/tool). + public sealed class ChatMessage + { + public string Role { get; init; } = "user"; + public string? Content { get; init; } + + /// Vom Modell angeforderte Tool-Aufrufe (nur Rolle assistant). + public List? ToolCalls { get; init; } + + /// Bezug auf den beantworteten Tool-Aufruf (nur Rolle tool). + public string? ToolCallId { get; init; } + + public static ChatMessage System(string content) => new() { Role = "system", Content = content }; + public static ChatMessage User(string content) => new() { Role = "user", Content = content }; + public static ChatMessage Assistant(string? content, List? toolCalls = null) => + new() { Role = "assistant", Content = content, ToolCalls = toolCalls }; + public static ChatMessage ToolResult(string toolCallId, string content) => + new() { Role = "tool", ToolCallId = toolCallId, Content = content }; + } + + /// Ein Tool-Aufruf des Modells (Function-Calling). + public sealed record ToolCall(string Id, string Name, string ArgumentsJson); + + /// Antwort des Modells: Text ODER Tool-Aufrufe (oder beides). + public sealed class ChatResponse + { + public string? Content { get; init; } + public List ToolCalls { get; init; } = new(); + public int PromptTokens { get; init; } + public int CompletionTokens { get; init; } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Agent/OpenRouterClient.cs b/src/PolyTrader.Modules.Supervisor/Agent/OpenRouterClient.cs new file mode 100644 index 0000000..a414506 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Agent/OpenRouterClient.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace PolyTrader.Modules.Supervisor.Agent +{ + /// Chat-Completion-Client (Function-Calling). Interface, damit der Agent testbar ist. + public interface IChatCompletionClient + { + Task CompleteAsync(string model, IReadOnlyList messages, + IReadOnlyList tools, CancellationToken ct); + } + + /// + /// OpenRouter-Client (OpenAI-kompatibles Chat-Completions-Schema inkl. Tools). + /// API-Key: Umgebungsvariable POLYTRADER_OPENROUTER_KEY, sonst gitignorierte Datei + /// openrouter.key im App-Ordner — GETRENNT vom Trading-Key (siehe Supervisor-Konzept §5). + /// SICHERHEIT: OpenRouter ist ein bewusst freigegebener externer Datenempfänger + /// (Egress-Allowlist, docs/sicherheit); es werden ausschließlich Analyse-Daten der + /// Tools gesendet, niemals Secrets. + /// + public sealed class OpenRouterClient : IChatCompletionClient + { + public const string Endpoint = "https://openrouter.ai/api/v1/chat/completions"; + + private readonly HttpClient _http; + private readonly Func _apiKeyProvider; + + public OpenRouterClient(HttpClient http, Func? apiKeyProvider = null) + { + _http = http; + _apiKeyProvider = apiKeyProvider ?? DefaultApiKeyProvider; + } + + /// Key aus env POLYTRADER_OPENROUTER_KEY, sonst aus gitignorierter openrouter.key. + public static string? DefaultApiKeyProvider() + { + string? key = Environment.GetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY"); + if (!string.IsNullOrWhiteSpace(key)) return key.Trim(); + string file = Path.Combine(AppContext.BaseDirectory, "openrouter.key"); + return File.Exists(file) ? File.ReadAllText(file).Trim() : null; + } + + public async Task CompleteAsync(string model, IReadOnlyList messages, + IReadOnlyList tools, CancellationToken ct) + { + string? apiKey = _apiKeyProvider(); + if (string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException( + "Kein OpenRouter-API-Key. Setze POLYTRADER_OPENROUTER_KEY (Umgebungsvariable) oder lege " + + "die Datei 'openrouter.key' in den App-Ordner (gitignored). Separater Key für den Supervisor empfohlen."); + + string body = BuildRequestBody(model, messages, tools); + using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint); + request.Headers.Add("Authorization", $"Bearer {apiKey}"); + request.Headers.Add("X-Title", "PolyTrader Supervisor"); + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + + using var response = await _http.SendAsync(request, ct); + string json = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"OpenRouter-Fehler {(int)response.StatusCode}: {Truncate(json, 500)}"); + + return ParseResponse(json); + } + + // ----- pure, testbare Serialisierung ----- + + internal static string BuildRequestBody(string model, IReadOnlyList messages, IReadOnlyList tools) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("model", model); + + w.WriteStartArray("messages"); + foreach (var m in messages) + { + w.WriteStartObject(); + w.WriteString("role", m.Role); + if (m.Content != null) w.WriteString("content", m.Content); + else w.WriteNull("content"); + if (m.ToolCalls is { Count: > 0 }) + { + w.WriteStartArray("tool_calls"); + foreach (var tc in m.ToolCalls) + { + w.WriteStartObject(); + w.WriteString("id", tc.Id); + w.WriteString("type", "function"); + w.WriteStartObject("function"); + w.WriteString("name", tc.Name); + w.WriteString("arguments", tc.ArgumentsJson); + w.WriteEndObject(); + w.WriteEndObject(); + } + w.WriteEndArray(); + } + if (m.ToolCallId != null) w.WriteString("tool_call_id", m.ToolCallId); + w.WriteEndObject(); + } + w.WriteEndArray(); + + if (tools.Count > 0) + { + w.WriteStartArray("tools"); + foreach (var t in tools) + { + w.WriteStartObject(); + w.WriteString("type", "function"); + w.WriteStartObject("function"); + w.WriteString("name", t.Name); + w.WriteString("description", t.Description); + w.WritePropertyName("parameters"); + using (var doc = JsonDocument.Parse(t.ParametersJsonSchema)) + doc.RootElement.WriteTo(w); + w.WriteEndObject(); + w.WriteEndObject(); + } + w.WriteEndArray(); + } + + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(ms.ToArray()); + } + + internal static ChatResponse ParseResponse(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var message = root.GetProperty("choices")[0].GetProperty("message"); + + string? content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String + ? c.GetString() : null; + + var toolCalls = new List(); + if (message.TryGetProperty("tool_calls", out var tcs) && tcs.ValueKind == JsonValueKind.Array) + { + foreach (var tc in tcs.EnumerateArray()) + { + string id = tc.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "" : ""; + var fn = tc.GetProperty("function"); + toolCalls.Add(new ToolCall(id, + fn.GetProperty("name").GetString() ?? "", + fn.TryGetProperty("arguments", out var a) ? a.GetString() ?? "{}" : "{}")); + } + } + + int promptTokens = 0, completionTokens = 0; + if (root.TryGetProperty("usage", out var usage)) + { + if (usage.TryGetProperty("prompt_tokens", out var pt)) promptTokens = pt.GetInt32(); + if (usage.TryGetProperty("completion_tokens", out var ctk)) completionTokens = ctk.GetInt32(); + } + + return new ChatResponse { Content = content, ToolCalls = toolCalls, PromptTokens = promptTokens, CompletionTokens = completionTokens }; + } + + private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "…"; + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs new file mode 100644 index 0000000..6d9c12b --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PolyTrader.Modules.Supervisor.Agent +{ + /// Ergebnis einer Agenten-Anfrage inkl. transparenter Tool-Aufruf-Historie. + public sealed class AgentResult + { + public string Answer { get; init; } = string.Empty; + public List<(string Tool, string Arguments, string Result)> ToolInvocations { get; init; } = new(); + public int PromptTokens { get; init; } + public int CompletionTokens { get; init; } + } + + /// + /// Der Analyse-Agent (S-2): Function-Calling-Loop gegen einen + /// mit der read-only . System-Kontext = Arbeitsanweisung + + /// Architektur-Dokument. Harte Iterationsgrenze gegen Endlosschleifen; jeder Tool-Aufruf wird + /// festgehalten (Nachvollziehbarkeit in der UI). + /// + public sealed class SupervisorAgent + { + public const int MaxIterations = 8; + public const string DefaultModel = "openrouter/auto"; + + private readonly IChatCompletionClient _chat; + private readonly SupervisorToolRegistry _tools; + + public SupervisorAgent(IChatCompletionClient chat, SupervisorToolRegistry tools) + { + _chat = chat; + _tools = tools; + } + + private static string SystemPrompt() => + "Du bist der Supervisor von PolyTrader: ein Analyse-Agent für automatisierten Polymarket-Handel. " + + "Du bist strikt read-only – du kannst und darfst nicht handeln. Nutze die Tools, um Entscheidungsjournal, " + + "Order-Events, Trades und Logs abzufragen, BEVOR du Schlüsse ziehst. Zitiere konkrete Daten " + + "(SignalIds, Zeiten, Preise, ReasonCodes). Antworte auf Deutsch, präzise und mit klarer Schlussfolgerung.\n\n" + + "=== ARCHITEKTUR-KONTEXT ===\n" + ArchitectureContext.Load(); + + /// + /// Beantwortet eine Analyse-Frage. meldet Tool-Aufrufe live an die UI. + /// + public async Task AskAsync(string question, string? model = null, + IProgress? progress = null, CancellationToken ct = default) + { + var messages = new List + { + ChatMessage.System(SystemPrompt()), + ChatMessage.User(question) + }; + var invocations = new List<(string, string, string)>(); + int promptTokens = 0, completionTokens = 0; + string usedModel = string.IsNullOrWhiteSpace(model) ? DefaultModel : model.Trim(); + + for (int i = 0; i < MaxIterations; i++) + { + ct.ThrowIfCancellationRequested(); + var response = await _chat.CompleteAsync(usedModel, messages, _tools.Tools, ct); + promptTokens += response.PromptTokens; + completionTokens += response.CompletionTokens; + + if (response.ToolCalls.Count == 0) + { + return new AgentResult + { + Answer = response.Content ?? "(keine Antwort)", + ToolInvocations = invocations, + PromptTokens = promptTokens, + CompletionTokens = completionTokens + }; + } + + messages.Add(ChatMessage.Assistant(response.Content, response.ToolCalls)); + foreach (var call in response.ToolCalls) + { + progress?.Report($"🔧 {call.Name}({call.ArgumentsJson})"); + string result = _tools.Execute(call.Name, call.ArgumentsJson); + invocations.Add((call.Name, call.ArgumentsJson, result)); + messages.Add(ChatMessage.ToolResult(call.Id, result)); + } + } + + return new AgentResult + { + Answer = "Abbruch: maximale Tool-Iterationen erreicht (Frage ggf. eingrenzen).", + ToolInvocations = invocations, + PromptTokens = promptTokens, + CompletionTokens = completionTokens + }; + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs new file mode 100644 index 0000000..246a52e --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace PolyTrader.Modules.Supervisor.Agent +{ + /// + /// Ein read-only-Analyse-Tool des Supervisors: Name, Beschreibung, JSON-Schema der Parameter und + /// die Ausführung. Tools LESEN ausschließlich (Journal, Events, Trades, Logs, KPIs) – es gibt + /// bewusst keinen Mechanismus, der handeln, canceln oder schreiben könnte. + /// + public sealed record SupervisorTool( + string Name, + string Description, + string ParametersJsonSchema, + Func Execute); + + /// + /// Transport-agnostische Tool-Registry (S-2): heute vom In-Prozess-Agenten genutzt, später + /// zusätzlich über MCP-Light exponierbar (S-4). Ausführung ist fehlertolerant – eine Tool-Exception + /// wird als Fehlertext an das Modell zurückgegeben, nie geworfen. + /// + public sealed class SupervisorToolRegistry + { + private readonly Dictionary _tools = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Tools => _tools.Values.ToList(); + + public void Register(SupervisorTool tool) => _tools[tool.Name] = tool; + + public string Execute(string name, string argumentsJson) + { + if (!_tools.TryGetValue(name, out var tool)) + return $"FEHLER: Unbekanntes Tool '{name}'. Verfügbar: {string.Join(", ", _tools.Keys)}"; + + try + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + return tool.Execute(doc.RootElement.Clone()); + } + catch (JsonException ex) + { + return $"FEHLER: Ungültige Tool-Argumente (kein JSON): {ex.Message}"; + } + catch (Exception ex) + { + return $"FEHLER bei Tool '{name}': {ex.Message}"; + } + } + + // ----- Argument-Helfer für Tool-Implementierungen ----- + + public static string? GetString(JsonElement args, string name) => + args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() : null; + + public static int? GetInt(JsonElement args, string name) => + args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number + ? v.GetInt32() : (int?)null; + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs new file mode 100644 index 0000000..8e1c9b0 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using PolyTrader.Core.Analytics; +using PolyTrader.Core.Persistence; +using PolyTrader.Modules.Supervisor.Services; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.Supervisor.Agent +{ + /// + /// Baut die Standard-Tool-Registry des Supervisors (S-2): read-only-Zugriffe auf + /// Entscheidungsjournal, Order-Events, Trade-Log, Dossiers, JSONL-Logs, KPIs und das + /// Architektur-Kontext-Dokument. Alle Ergebnisse als kompakte JSON-/Markdown-Strings. + /// + public static class SupervisorTools + { + private static readonly JsonSerializerOptions JsonOpts = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + public static SupervisorToolRegistry CreateRegistry( + IDecisionJournal journal, + IOrderEventLog orderEvents, + ITradeLogRepository tradeLog, + DossierService dossiers) + { + var reg = new SupervisorToolRegistry(); + string logsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + + reg.Register(new SupervisorTool( + "query_decisions", + "Fragt das Entscheidungsjournal ab (JEDE Handelsentscheidung inkl. Ablehnungen mit Grund). " + + "Filter optional: accountId, tokenId, reason (z.B. MaxBuyPriceExceeded), decision (Executed/Rejected/Skipped/Failed), sinceHours.", + """{"type":"object","properties":{"accountId":{"type":"integer"},"tokenId":{"type":"string"},"reason":{"type":"string"},"decision":{"type":"string"},"sinceHours":{"type":"integer"},"limit":{"type":"integer"}}}""", + args => + { + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500); + int? accountId = SupervisorToolRegistry.GetInt(args, "accountId"); + string? tokenId = SupervisorToolRegistry.GetString(args, "tokenId"); + string? reason = SupervisorToolRegistry.GetString(args, "reason"); + string? decision = SupervisorToolRegistry.GetString(args, "decision"); + int? sinceHours = SupervisorToolRegistry.GetInt(args, "sinceHours"); + DateTime since = sinceHours.HasValue ? DateTime.UtcNow.AddHours(-sinceHours.Value) : DateTime.MinValue; + + var rows = journal.Query(d => + (accountId == null || d.AccountId == accountId) && + (tokenId == null || d.TokenId == tokenId) && + d.Timestamp >= since, limit * 3) + .Where(d => reason == null || string.Equals(d.Reason.ToString(), reason, StringComparison.OrdinalIgnoreCase)) + .Where(d => decision == null || string.Equals(d.Decision.ToString(), decision, StringComparison.OrdinalIgnoreCase)) + .Take(limit) + .Select(d => new + { + d.SignalId, ts = d.Timestamp, module = d.ModuleName, d.AccountId, d.IsDemo, + d.TokenId, market = d.MarketQuestion, d.Side, price = d.SignalPrice, + decision = d.Decision.ToString(), reason = d.Reason.ToString(), d.Message, ctx = d.ContextJson + }); + return JsonSerializer.Serialize(rows, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "query_order_events", + "Fragt das Order-Lifecycle-Log ab (Platzierungen, CLOB-Antworten, Cancels, Leiter-Stufen). " + + "Filter optional: accountId, tokenId, signalId, sinceHours.", + """{"type":"object","properties":{"accountId":{"type":"integer"},"tokenId":{"type":"string"},"signalId":{"type":"string"},"sinceHours":{"type":"integer"},"limit":{"type":"integer"}}}""", + args => + { + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500); + int? accountId = SupervisorToolRegistry.GetInt(args, "accountId"); + string? tokenId = SupervisorToolRegistry.GetString(args, "tokenId"); + string? signalId = SupervisorToolRegistry.GetString(args, "signalId"); + int? sinceHours = SupervisorToolRegistry.GetInt(args, "sinceHours"); + DateTime since = sinceHours.HasValue ? DateTime.UtcNow.AddHours(-sinceHours.Value) : DateTime.MinValue; + + var rows = orderEvents.Query(e => + (accountId == null || e.AccountId == accountId) && + (tokenId == null || e.TokenId == tokenId) && + (signalId == null || e.SignalId == signalId) && + e.Timestamp >= since, limit) + .Select(e => new + { + e.SignalId, ts = e.Timestamp, module = e.ModuleName, e.AccountId, e.TokenId, + eventType = e.EventType.ToString(), e.Side, e.Price, e.AmountUsd, e.OrderType, + e.Response, details = e.DetailsJson + }); + return JsonSerializer.Serialize(rows, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "query_trades", + "Fragt abgeschlossene Trades aus dem modulübergreifenden Trade-Log ab. " + + "Filter optional: accountId, moduleName, sinceDays, onlyLosers (true = nur Verlierer).", + """{"type":"object","properties":{"accountId":{"type":"integer"},"moduleName":{"type":"string"},"sinceDays":{"type":"integer"},"onlyLosers":{"type":"boolean"},"limit":{"type":"integer"}}}""", + args => + { + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500); + int? accountId = SupervisorToolRegistry.GetInt(args, "accountId"); + string? module = SupervisorToolRegistry.GetString(args, "moduleName"); + int? sinceDays = SupervisorToolRegistry.GetInt(args, "sinceDays"); + bool onlyLosers = args.ValueKind == JsonValueKind.Object && + args.TryGetProperty("onlyLosers", out var ol) && ol.ValueKind == JsonValueKind.True; + DateTime since = sinceDays.HasValue ? DateTime.UtcNow.AddDays(-sinceDays.Value) : DateTime.MinValue; + + var rows = tradeLog.Find(t => + (accountId == null || t.AccountId == accountId) && + (module == null || t.ModuleName == module) && + t.ClosedAt >= since) + .Where(t => !onlyLosers || t.RealizedPnl < 0) + .OrderByDescending(t => t.ClosedAt) + .Take(limit) + .Select(t => new + { + t.SignalId, t.ModuleName, t.AccountId, t.IsDemo, t.TokenId, market = t.MarketQuestion, + t.Outcome, t.Side, t.EntryPrice, t.ExitPrice, t.Size, t.RealizedPnl, t.PnlPercent, + t.OpenedAt, t.ClosedAt, t.ExitReason + }); + return JsonSerializer.Serialize(rows, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "get_dossier", + "Liefert das komplette Dossier zu einer SignalId als Markdown: Entscheidungskette, Order-Events, Trades, Log-Auszug.", + """{"type":"object","properties":{"signalId":{"type":"string"}},"required":["signalId"]}""", + args => + { + string? signalId = SupervisorToolRegistry.GetString(args, "signalId"); + if (string.IsNullOrWhiteSpace(signalId)) return "FEHLER: signalId fehlt."; + return DossierBuilder.ToMarkdown(dossiers.BuildForSignal(signalId)); + })); + + reg.Register(new SupervisorTool( + "read_logs", + "Liest die JSONL-Logdatei eines Tages (Datum yyyy-MM-dd), optional gefiltert nach level, cid (SignalId) und textFilter.", + """{"type":"object","properties":{"date":{"type":"string"},"level":{"type":"string"},"cid":{"type":"string"},"textFilter":{"type":"string"},"limit":{"type":"integer"}},"required":["date"]}""", + args => + { + string? date = SupervisorToolRegistry.GetString(args, "date"); + if (string.IsNullOrWhiteSpace(date)) return "FEHLER: date fehlt (yyyy-MM-dd)."; + string path = Path.Combine(logsDir, $"{date}.jsonl"); + if (!File.Exists(path)) return $"Keine JSONL-Datei für {date}."; + + string? level = SupervisorToolRegistry.GetString(args, "level"); + string? cid = SupervisorToolRegistry.GetString(args, "cid"); + string? text = SupervisorToolRegistry.GetString(args, "textFilter"); + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 200, 1, 1000); + + var lines = new List(); + foreach (var line in File.ReadLines(path)) + { + var p = LogJson.ParseLine(line); + if (p == null) continue; + if (level != null && !string.Equals(p.Level, level, StringComparison.OrdinalIgnoreCase)) continue; + if (cid != null && p.Cid != cid) continue; + if (text != null && !p.Message.Contains(text, StringComparison.OrdinalIgnoreCase)) continue; + lines.Add(p); + if (lines.Count >= limit) break; + } + return JsonSerializer.Serialize(lines, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "get_kpis", + "Berechnet Kennzahlen (Netto-PnL, Winrate, Ø-PnL, Profit-Faktor, Trade-Anzahl) über das Trade-Log. " + + "Filter optional: accountId, moduleName, sinceDays, isDemo.", + """{"type":"object","properties":{"accountId":{"type":"integer"},"moduleName":{"type":"string"},"sinceDays":{"type":"integer"},"isDemo":{"type":"boolean"}}}""", + args => + { + int? accountId = SupervisorToolRegistry.GetInt(args, "accountId"); + string? module = SupervisorToolRegistry.GetString(args, "moduleName"); + int? sinceDays = SupervisorToolRegistry.GetInt(args, "sinceDays"); + bool? isDemo = args.ValueKind == JsonValueKind.Object && args.TryGetProperty("isDemo", out var d) && + (d.ValueKind == JsonValueKind.True || d.ValueKind == JsonValueKind.False) + ? d.GetBoolean() : (bool?)null; + DateTime since = sinceDays.HasValue ? DateTime.UtcNow.AddDays(-sinceDays.Value) : DateTime.MinValue; + + var trades = tradeLog.Find(t => + (accountId == null || t.AccountId == accountId) && + (module == null || t.ModuleName == module) && + (isDemo == null || t.IsDemo == isDemo) && + t.ClosedAt >= since); + var k = TradeAnalytics.ComputeKpis(trades); + var byModule = TradeAnalytics.PnlByKey(trades, t => t.ModuleName); + return JsonSerializer.Serialize(new + { + k.TradeCount, k.NetPnl, k.WinRatePct, k.AvgPnlPerTrade, k.ProfitFactor, + byModule = byModule.Select(x => new { module = x.Key, x.Pnl, x.Count }) + }, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "get_architecture_context", + "Liefert das kuratierte Architektur-/Verhaltensdokument von PolyTrader (wie die Software entscheidet und handelt).", + """{"type":"object","properties":{}}""", + _ => ArchitectureContext.Load())); + + return reg; + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Context/ArchitectureContext.md b/src/PolyTrader.Modules.Supervisor/Context/ArchitectureContext.md new file mode 100644 index 0000000..8e37bc4 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Context/ArchitectureContext.md @@ -0,0 +1,65 @@ +# PolyTrader — Architektur- und Verhaltenskontext (für den Supervisor-Agenten) + +> Kuratierte, destillierte Beschreibung, WIE die Software entscheidet und handelt. +> Bei Änderungen am Geld-Pfad mitpflegen. Stand: 2026-07. + +## System +PolyTrader ist eine .NET-8-WinForms-App für automatisierten Handel auf Polymarket (Prediction Markets, +USDC auf Polygon, Gnosis-Safe-Wallets). Aufbau: **Core** (Accounts, Markt-Cache, CLOB-/Data-API-Clients, +Trade-Log, Entscheidungsjournal) + unabhängige **Module**: **CopyTrading**, **ResolutionFarming**, +**Supervisor** (du — strikt read-only, du kannst NIEMALS handeln). Es gibt Live- und Demo-Accounts; +Demo simuliert Fills realistisch (Exit-Slippage + Taker-Fees). + +## Datenmodell für deine Analysen +- **core_decision_journal** (`query_decisions`): JEDE Handelsentscheidung — `Decision` + (Executed/Rejected/Skipped/Failed) + `Reason` (ReasonCode) + Kontext-JSON + SignalId. Auch + Ablehnungen! „Kein Trade" ist hier immer begründet. +- **core_order_events** (`query_order_events`): Order-Lifecycle — Platzierungen mit CLOB-Antwort, + Cancels, SELL-Leiter-Stufen (LadderStart/LadderStep/FloorReplaced/DustAbort), + Cleanup-/Reconciliation-Cancels. +- **core_trade_log** (`query_trades`, `get_kpis`): abgeschlossene Trades aller Module (realisierter PnL). +- **SignalId**: verbindet Signal → Entscheidungen → Orders → Trade. `get_dossier(signalId)` liefert + die komplette Kette. **Beginne Einzelfall-Analysen immer mit dem Dossier.** +- **JSONL-Logs** (`read_logs`): Freitext-Verlauf je Tag, `cid` = SignalId. + +## CopyTrading (Modul 1) +Kopiert Trades beobachteter „Master-Trader" (on-chain via Alchemy-WSS erkannt, plus API-Polling). +**BUY-Pfad** (Reihenfolge der Checks; jeder Fehlschlag = journalisierter Reject): +Modus (Inactive/SellOnly) → ExitPending-Skip (H3: keine Zukäufe während des Ausstiegs) → MaxBuyPrice → +PerMaster-Limit → Zeitfenster-Limits (Restlaufzeit-Buckets 6h/24h/72h/None) → Markt-Budget (PerMarket, +Low-Balance-Bypass „6-Shares-Minimum") → verfügbares Guthaben → Polymarket-Minimum (5.5 Shares/0.10 USDC). +Limit-Preis: HF-Master (High-Frequency) = Signalpreis + 0.005 fest; sonst prozentualer Aufschlag +(MaxPriceDifference), gedeckelt (MaxBuyPrice, hart 0.99). Maker-Entry-Option: GTC am Signalpreis ohne +Aufschlag (0 Fees), sonst GTD. +**SELL-Pfad**: Spam-Blockade (<20s) → Leiter-aktiv-Skip → Ownership-Check (Position muss vom selben +Master stammen; System-Signale TraderId==0 = Marktauflösung sind ausgenommen) → Teilverkaufs-Filter +(Master verkauft <30% seines Bestands = Rauschen, wird ignoriert) → Tracking-Inkonsistenz/Schonfrist. +**SELL-Eskalationsleiter** (statt Market-Dump): GTC-Limit nahe Master-Exit, ohne Fill stufenweises +Nachpreisen (−3% relativ je Stufe; HF ~20s, sonst ~120s Intervall) bis zum **Floor** +(Master-Exit × (1 − SellFloorPct)). Am Floor: halten + Benachrichtigung; die Floor-Order wird +überwacht und bei Verschwinden neu platziert. Dust-Reste unter Minimum beenden die Leiter. +**Take-Profit** (ProfitTarget, meist 9999 = deaktiviert) startet dieselbe Leiter. PreRedeem hat Vorrang. +Fees: Taker kategorieabhängig (~Sports 0.75%, Politik 1.0%, Krypto 1.8%, Geopolitik 0), Maker 0. + +## ResolutionFarming (Modul 2) +Kauft unterbewertete Favoriten (0.90–0.98) in bald auflösenden Märkten (<48h), hält bis Resolution. +Scanner filtert (Preisband → Kategorie-Whitelist → Blacklist → Auflösungsfenster → Netto-Edge nach +Fees ≥ MinEdge); ALLE Kandidaten inkl. Rejects stehen in rf_candidates. Risiko-Limits: MaxPerMarket, +MaxPerCluster (korrelierte Events teilen einen Cluster-Key), MaxTotalExposure, Tages-Drossel, +Tagesverlust-Kill-Switch. Ein verlorener 95¢-Trade vernichtet ~19 Gewinner — das Risikomodell IST +die Strategie. Aktuell Demo (Maker-Fill 0 Fee); Live-Anbindung folgt. + +## Bekannte Eigenheiten / typische Fehlerbilder +- API-Lag: Positions-API zeigt geschlossene Positionen kurz weiter → Dedup-Guards (`_processedClosures`). +- Demo-Fills sind Näherungen (Signalpreis − halber Spread) — Demo-PnL ist leicht optimistisch bei + illiquiden Märkten. +- Latenz Master-Trade → unser Fill kostet Marge (Preisdifferenz Signal vs. orderPrice im Journal-Kontext). +- Rejects wegen TimeWindow/Budget sind NORMAL (Risikomodell) — häufe Rejects nur dann als Problem, + wenn sie systematisch profitable Trades verhindern (mit Marktausgang gegenprüfen). +- „Position nicht gefunden" bei SELLs: oft wurde der BUY zuvor gefiltert (im Journal nachsehen!). + +## Deine Arbeitsweise +1. Daten VOR Meinung: nutze Tools, zitiere konkrete Zahlen/IDs (SignalId, Zeiten, Preise). +2. Einzelfall: `get_dossier` zuerst. Muster: `query_decisions`/`query_trades` mit Filtern, dann Drilldown. +3. Unterscheide „schlechtes Signal" (Master/Markt) von „schlechter Ausführung" (Latenz/Fees/Leiter). +4. Antworte auf Deutsch, präzise, mit klarer Schlussfolgerung und ggf. konkreten Verbesserungsvorschlägen. diff --git a/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj b/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj index a1835e4..790239d 100644 --- a/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj +++ b/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj @@ -11,6 +11,11 @@ + + + + + net8.0-windows enable diff --git a/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs b/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs index 9895f9f..13688a6 100644 --- a/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs +++ b/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs @@ -20,9 +20,21 @@ namespace PolyTrader.Modules.Supervisor public void RegisterServices(IServiceCollection services, IConfiguration configuration) { - // S-1: reine Beschaffung/Aufbereitung. Eigene sup_-Persistenz (Berichte/Konversationen) - // folgt mit S-2/S-3. + // S-1: Dossier-Beschaffung/-Aufbereitung. services.AddSingleton(); + + // S-2: read-only Tool-Registry + OpenRouter-Agent. Der API-Key kommt aus + // POLYTRADER_OPENROUTER_KEY bzw. gitignorierter openrouter.key (getrennt vom Trading). + services.AddSingleton(sp => Agent.SupervisorTools.CreateRegistry( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + services.AddSingleton(_ => + new Agent.OpenRouterClient(new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMinutes(3) })); + services.AddSingleton(); + + // sup_-Persistenz (Berichte/Konversationen) + Profile folgen mit S-3. } public void RegisterUi(IModuleUiHost host, System.IServiceProvider services) diff --git a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs index 9a0ec3f..c2611d8 100644 --- a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs +++ b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs @@ -3,26 +3,44 @@ using System.Collections.Generic; using System.Windows.Forms; using Microsoft.Extensions.DependencyInjection; using PolyTrader.Core.Analytics; +using PolyTrader.Modules.Supervisor.Agent; using PolyTrader.Modules.Supervisor.Services; namespace PolyTrader.Modules.Supervisor.Ui { /// - /// Hauptfenster des Supervisor-Moduls (S-1): Dossier-Browser — links die jüngsten Signale - /// (aus dem Entscheidungsjournal), rechts das komplette Dossier (Markdown). Der Analyse-Chat - /// (OpenRouter-Agent) folgt in S-2 als weiterer Tab. Code-only konstruiert (Muster RF-Modul); - /// DB-Zugriffe defensiv, damit die UI auch bei leerer/nicht erreichbarer DB bedienbar bleibt. + /// Hauptfenster des Supervisor-Moduls: Tab „Analyse" (Chat mit dem read-only-Agenten, + /// Tool-Aufrufe transparent im Verlauf) und Tab „Dossiers" (Signal-Browser mit Markdown-Dossier). + /// Code-only konstruiert (Muster RF-Modul); DB-/API-Zugriffe defensiv. /// public sealed class SupervisorMainForm : Form { private DossierService? _dossiers; + private SupervisorAgent? _agent; - private readonly ToolStrip _toolStrip = new(); + // ----- Tab Analyse (Chat) ----- + private readonly ToolStrip _chatStrip = new(); + private readonly ToolStripLabel _lblModel = new() { Text = "Modell:" }; + private readonly ToolStripTextBox _tbModel = new() { AutoSize = false, Width = 220, Text = SupervisorAgent.DefaultModel }; + private readonly ToolStripButton _btnClearChat = new() { Text = "Verlauf leeren", DisplayStyle = ToolStripItemDisplayStyle.Text }; + private readonly RichTextBox _chatLog = new() + { + Dock = DockStyle.Fill, ReadOnly = true, BackColor = System.Drawing.Color.White, + Font = new System.Drawing.Font("Segoe UI", 9.5f) + }; + private readonly TextBox _chatInput = new() + { + Dock = DockStyle.Fill, Multiline = true, Height = 54, + PlaceholderText = "Analyse-Frage stellen … (Strg+Enter zum Senden)" + }; + private readonly Button _btnSend = new() { Text = "Senden", Dock = DockStyle.Right, Width = 110 }; + + // ----- Tab Dossiers (Browser) ----- + private readonly ToolStrip _dossierStrip = new(); private readonly ToolStripButton _btnRefresh = new() { Text = "Aktualisieren", DisplayStyle = ToolStripItemDisplayStyle.Text }; private readonly ToolStripLabel _lblSearch = new() { Text = "SignalId:" }; private readonly ToolStripTextBox _tbSignalId = new() { AutoSize = false, Width = 220 }; private readonly ToolStripButton _btnOpen = new() { Text = "Dossier öffnen", DisplayStyle = ToolStripItemDisplayStyle.Text }; - private readonly SplitContainer _split = new() { Dock = DockStyle.Fill, SplitterDistance = 420 }; private readonly DataGridView _grid = new() { @@ -35,24 +53,49 @@ namespace PolyTrader.Modules.Supervisor.Ui Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, Font = new System.Drawing.Font("Consolas", 9.5f), WordWrap = false }; + private readonly Label _status = new() { Dock = DockStyle.Bottom, Height = 22, Padding = new Padding(6, 2, 6, 2), Text = "" }; public SupervisorMainForm() { Text = "Supervisor"; Width = 1250; - Height = 700; + Height = 720; StartPosition = FormStartPosition.CenterScreen; - _toolStrip.Items.AddRange(new ToolStripItem[] { _btnRefresh, new ToolStripSeparator(), _lblSearch, _tbSignalId, _btnOpen }); + var tabs = new TabControl { Dock = DockStyle.Fill }; + + // --- Tab Analyse --- + var tabAnalyse = new TabPage("Analyse"); + _chatStrip.Items.AddRange(new ToolStripItem[] { _lblModel, _tbModel, new ToolStripSeparator(), _btnClearChat }); + var inputPanel = new Panel { Dock = DockStyle.Bottom, Height = 60, Padding = new Padding(4) }; + inputPanel.Controls.Add(_chatInput); + inputPanel.Controls.Add(_btnSend); + tabAnalyse.Controls.Add(_chatLog); + tabAnalyse.Controls.Add(inputPanel); + tabAnalyse.Controls.Add(_chatStrip); + _chatStrip.Dock = DockStyle.Top; + + // --- Tab Dossiers --- + var tabDossiers = new TabPage("Dossiers"); + _dossierStrip.Items.AddRange(new ToolStripItem[] { _btnRefresh, new ToolStripSeparator(), _lblSearch, _tbSignalId, _btnOpen }); _split.Panel1.Controls.Add(_grid); _split.Panel2.Controls.Add(_dossierText); + tabDossiers.Controls.Add(_split); + tabDossiers.Controls.Add(_dossierStrip); + _dossierStrip.Dock = DockStyle.Top; - Controls.Add(_split); - Controls.Add(_toolStrip); + tabs.TabPages.AddRange(new[] { tabAnalyse, tabDossiers }); + Controls.Add(tabs); Controls.Add(_status); - _toolStrip.Dock = DockStyle.Top; + // Verhalten + _btnSend.Click += async (_, _) => await SendQuestionAsync(); + _chatInput.KeyDown += async (_, e) => + { + if (e.Control && e.KeyCode == Keys.Enter) { e.SuppressKeyPress = true; await SendQuestionAsync(); } + }; + _btnClearChat.Click += (_, _) => _chatLog.Clear(); _btnRefresh.Click += (_, _) => LoadSignals(); _btnOpen.Click += (_, _) => OpenDossier(_tbSignalId.Text.Trim()); _grid.SelectionChanged += (_, _) => OpenSelected(); @@ -61,9 +104,55 @@ namespace PolyTrader.Modules.Supervisor.Ui public void Initialize(IServiceProvider services) { _dossiers = services.GetRequiredService(); + _agent = services.GetRequiredService(); LoadSignals(); + AppendChat("System", "Supervisor bereit. Read-only-Analyse über Entscheidungsjournal, Order-Events, Trades und Logs. " + + "API-Key: POLYTRADER_OPENROUTER_KEY oder Datei openrouter.key.", System.Drawing.Color.Gray); } + // ===== Analyse-Chat ===== + + private async System.Threading.Tasks.Task SendQuestionAsync() + { + if (_agent == null) return; + string question = _chatInput.Text.Trim(); + if (question.Length == 0) return; + + _chatInput.Text = ""; + _btnSend.Enabled = false; + AppendChat("Du", question, System.Drawing.Color.DarkBlue); + _status.Text = "Analyse läuft …"; + + var progress = new Progress(msg => AppendChat("Tool", msg, System.Drawing.Color.DarkGoldenrod)); + try + { + var result = await System.Threading.Tasks.Task.Run(() => + _agent.AskAsync(question, _tbModel.Text, progress)); + AppendChat("Supervisor", result.Answer, System.Drawing.Color.Black); + _status.Text = $"Fertig. {result.ToolInvocations.Count} Tool-Aufruf(e), ~{result.PromptTokens + result.CompletionTokens} Tokens."; + } + catch (Exception ex) + { + AppendChat("Fehler", ex.Message, System.Drawing.Color.Firebrick); + _status.Text = "Fehler bei der Analyse."; + } + finally + { + _btnSend.Enabled = true; + } + } + + private void AppendChat(string who, string text, System.Drawing.Color color) + { + if (InvokeRequired) { BeginInvoke(() => AppendChat(who, text, color)); return; } + _chatLog.SelectionStart = _chatLog.TextLength; + _chatLog.SelectionColor = color; + _chatLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {who}: {text}{Environment.NewLine}{Environment.NewLine}"); + _chatLog.ScrollToCaret(); + } + + // ===== Dossier-Browser ===== + private void LoadSignals() { if (_dossiers == null) return; diff --git a/tests/PolyTrader.Tests/SupervisorAgentTests.cs b/tests/PolyTrader.Tests/SupervisorAgentTests.cs new file mode 100644 index 0000000..7ac5339 --- /dev/null +++ b/tests/PolyTrader.Tests/SupervisorAgentTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.Supervisor.Agent; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// Sicherheitsnetz für den S-2-Agenten: Tool-Registry (Ausführung, Fehler, Unbekanntes), + /// Function-Calling-Loop (Tool-Ergebnisse fließen zurück, Iterationsgrenze) und die + /// OpenRouter-Serialisierung (Request-Body pur, Response-Parsing). + /// + public class SupervisorAgentTests + { + // ----- Registry ----- + + private static SupervisorToolRegistry RegistryWithEcho() + { + var reg = new SupervisorToolRegistry(); + reg.Register(new SupervisorTool("echo", "Echo", + """{"type":"object","properties":{"text":{"type":"string"}}}""", + args => "ECHO:" + (SupervisorToolRegistry.GetString(args, "text") ?? ""))); + reg.Register(new SupervisorTool("boom", "wirft", + """{"type":"object","properties":{}}""", + _ => throw new InvalidOperationException("kaputt"))); + return reg; + } + + [Fact] + public void Registry_executes_tool_with_args() + { + Assert.Equal("ECHO:hallo", RegistryWithEcho().Execute("echo", """{"text":"hallo"}""")); + } + + [Fact] + public void Registry_unknown_tool_and_broken_args_return_error_strings() + { + var reg = RegistryWithEcho(); + Assert.StartsWith("FEHLER: Unbekanntes Tool", reg.Execute("gibtsnicht", "{}")); + Assert.StartsWith("FEHLER: Ungültige Tool-Argumente", reg.Execute("echo", "{kein json")); + Assert.StartsWith("FEHLER bei Tool 'boom'", reg.Execute("boom", "{}")); // Exception -> Text, wirft nie + } + + // ----- Agent-Loop ----- + + private sealed class ScriptedChatClient : IChatCompletionClient + { + private readonly Queue _script; + public List> Requests { get; } = new(); + + public ScriptedChatClient(params ChatResponse[] script) => _script = new Queue(script); + + public Task CompleteAsync(string model, IReadOnlyList messages, + IReadOnlyList tools, CancellationToken ct) + { + Requests.Add(new List(messages)); + return Task.FromResult(_script.Count > 0 ? _script.Dequeue() : new ChatResponse { Content = "leer" }); + } + } + + [Fact] + public async Task Agent_executes_tool_calls_and_feeds_results_back() + { + var chat = new ScriptedChatClient( + new ChatResponse { ToolCalls = { new ToolCall("c1", "echo", """{"text":"daten"}""") } }, + new ChatResponse { Content = "Fertige Analyse." }); + var agent = new SupervisorAgent(chat, RegistryWithEcho()); + + var result = await agent.AskAsync("Warum X?"); + + Assert.Equal("Fertige Analyse.", result.Answer); + Assert.Single(result.ToolInvocations); + Assert.Equal(("echo", """{"text":"daten"}""", "ECHO:daten"), result.ToolInvocations[0]); + + // Zweiter Request enthält Assistant-ToolCall + Tool-Ergebnis. + var second = chat.Requests[1]; + Assert.Contains(second, m => m.Role == "assistant" && m.ToolCalls is { Count: 1 }); + Assert.Contains(second, m => m.Role == "tool" && m.ToolCallId == "c1" && m.Content == "ECHO:daten"); + // System-Prompt enthält den Architektur-Kontext. + Assert.Contains("ARCHITEKTUR-KONTEXT", second[0].Content); + } + + [Fact] + public async Task Agent_stops_at_max_iterations() + { + // Modell fordert ENDLOS Tools an -> Agent bricht kontrolliert ab. + var endless = Enumerable.Range(0, SupervisorAgent.MaxIterations + 2) + .Select(i => new ChatResponse { ToolCalls = { new ToolCall($"c{i}", "echo", "{}") } }) + .ToArray(); + var agent = new SupervisorAgent(new ScriptedChatClient(endless), RegistryWithEcho()); + + var result = await agent.AskAsync("loop"); + + Assert.Contains("maximale Tool-Iterationen", result.Answer); + Assert.Equal(SupervisorAgent.MaxIterations, result.ToolInvocations.Count); + } + + // ----- OpenRouter-Serialisierung (pur) ----- + + [Fact] + public void BuildRequestBody_produces_openai_compatible_json() + { + var messages = new List + { + ChatMessage.System("sys"), + ChatMessage.User("frage"), + ChatMessage.Assistant(null, new List { new("c1", "echo", """{"text":"x"}""") }), + ChatMessage.ToolResult("c1", "ergebnis") + }; + var tools = new List + { + new("echo", "Echo-Tool", """{"type":"object","properties":{"text":{"type":"string"}}}""", _ => "") + }; + + string body = OpenRouterClient.BuildRequestBody("openrouter/auto", messages, tools); + using var doc = JsonDocument.Parse(body); // valides JSON + var root = doc.RootElement; + + Assert.Equal("openrouter/auto", root.GetProperty("model").GetString()); + var msgs = root.GetProperty("messages"); + Assert.Equal(4, msgs.GetArrayLength()); + Assert.Equal("function", msgs[2].GetProperty("tool_calls")[0].GetProperty("type").GetString()); + Assert.Equal("echo", msgs[2].GetProperty("tool_calls")[0].GetProperty("function").GetProperty("name").GetString()); + Assert.Equal("c1", msgs[3].GetProperty("tool_call_id").GetString()); + Assert.Equal("echo", root.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString()); + } + + [Fact] + public void ParseResponse_reads_content_toolcalls_and_usage() + { + const string json = """ + {"choices":[{"message":{"content":null,"tool_calls":[ + {"id":"call_1","type":"function","function":{"name":"query_trades","arguments":"{\"limit\":5}"}}]}}], + "usage":{"prompt_tokens":120,"completion_tokens":30}} + """; + var r = OpenRouterClient.ParseResponse(json); + + Assert.Null(r.Content); + Assert.Single(r.ToolCalls); + Assert.Equal("query_trades", r.ToolCalls[0].Name); + Assert.Equal("""{"limit":5}""", r.ToolCalls[0].ArgumentsJson); + Assert.Equal(120, r.PromptTokens); + Assert.Equal(30, r.CompletionTokens); + } + + [Fact] + public void ArchitectureContext_is_embedded_and_loads() + { + string ctx = ArchitectureContext.Load(); + Assert.Contains("PolyTrader", ctx); + Assert.Contains("read-only", ctx); + } + } +}