Supervisor S-2: OpenRouter-Agent, read-only Tool-Registry, Analyse-Chat

Der KI-Analyse-Agent (docs/konzepte/KONZEPT-Modul-Supervisor.md, Phase S-2):
- SupervisorToolRegistry (transport-agnostisch, spaeter auch MCP-Light): 7 read-only-Tools -
  query_decisions (inkl. Rejects+ReasonCodes), query_order_events, query_trades, get_dossier
  (Markdown-Kette), read_logs (JSONL je Tag, CID-Filter), get_kpis (TradeAnalytics),
  get_architecture_context. Ausfuehrung fehlertolerant (Exception -> Fehlertext, wirft nie).
  KEIN Tool kann handeln/schreiben.
- OpenRouterClient (IChatCompletionClient): OpenAI-kompatibles Chat-Completions-Schema inkl.
  Function-Calling; Request-Bau + Response-Parsing pur/testbar. API-Key GETRENNT vom Trading:
  env POLYTRADER_OPENROUTER_KEY oder gitignorierte openrouter.key (in .gitignore aufgenommen).
- SupervisorAgent: Function-Calling-Loop (max 8 Iterationen), System-Prompt = Arbeitsanweisung +
  eingebettetes Architektur-Kontext-Dokument (Context/ArchitectureContext.md, EmbeddedResource,
  mit dem Code versioniert - beschreibt Entscheidungswege, ReasonCodes, Leiter-Mechanik, Eigenheiten).
  Tool-Aufrufe werden gesammelt und in der UI transparent angezeigt.
- SupervisorMainForm: neuer Tab 'Analyse' (Chat, Modellwahl default openrouter/auto, Tool-Aufrufe
  live im Verlauf, Token-Zaehler) neben dem Dossiers-Tab.
- Sicherheitskonzept: OpenRouter als bewusst freigegebener Egress dokumentiert (nur Tool-Ergebnisse,
  nie Secrets; Spend-Limit je Key empfohlen).

Tests: +7 (Registry-Ausfuehrung/Fehler, Agent-Loop mit Tool-Rueckfluss, Iterationsgrenze,
Request-Body/Response-Parsing, eingebetteter Kontext). Build 0 Fehler, 351 Tests gruen,
--smoke-ui alle 5 Views gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-17 18:06:28 +02:00
co-authored by Claude Opus 4.8
parent c75a958e36
commit 5d732277b2
13 changed files with 941 additions and 17 deletions
+1
View File
@@ -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
+9 -4
View File
@@ -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.
---
@@ -0,0 +1,23 @@
using System.IO;
using System.Reflection;
namespace PolyTrader.Modules.Supervisor.Agent
{
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
@@ -0,0 +1,36 @@
using System.Collections.Generic;
namespace PolyTrader.Modules.Supervisor.Agent
{
/// <summary>Chat-Nachricht im OpenAI-/OpenRouter-Schema (Rollen: system/user/assistant/tool).</summary>
public sealed class ChatMessage
{
public string Role { get; init; } = "user";
public string? Content { get; init; }
/// <summary>Vom Modell angeforderte Tool-Aufrufe (nur Rolle assistant).</summary>
public List<ToolCall>? ToolCalls { get; init; }
/// <summary>Bezug auf den beantworteten Tool-Aufruf (nur Rolle tool).</summary>
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<ToolCall>? toolCalls = null) =>
new() { Role = "assistant", Content = content, ToolCalls = toolCalls };
public static ChatMessage ToolResult(string toolCallId, string content) =>
new() { Role = "tool", ToolCallId = toolCallId, Content = content };
}
/// <summary>Ein Tool-Aufruf des Modells (Function-Calling).</summary>
public sealed record ToolCall(string Id, string Name, string ArgumentsJson);
/// <summary>Antwort des Modells: Text ODER Tool-Aufrufe (oder beides).</summary>
public sealed class ChatResponse
{
public string? Content { get; init; }
public List<ToolCall> ToolCalls { get; init; } = new();
public int PromptTokens { get; init; }
public int CompletionTokens { get; init; }
}
}
@@ -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
{
/// <summary>Chat-Completion-Client (Function-Calling). Interface, damit der Agent testbar ist.</summary>
public interface IChatCompletionClient
{
Task<ChatResponse> CompleteAsync(string model, IReadOnlyList<ChatMessage> messages,
IReadOnlyList<SupervisorTool> tools, CancellationToken ct);
}
/// <summary>
/// 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.
/// </summary>
public sealed class OpenRouterClient : IChatCompletionClient
{
public const string Endpoint = "https://openrouter.ai/api/v1/chat/completions";
private readonly HttpClient _http;
private readonly Func<string?> _apiKeyProvider;
public OpenRouterClient(HttpClient http, Func<string?>? apiKeyProvider = null)
{
_http = http;
_apiKeyProvider = apiKeyProvider ?? DefaultApiKeyProvider;
}
/// <summary>Key aus env POLYTRADER_OPENROUTER_KEY, sonst aus gitignorierter openrouter.key.</summary>
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<ChatResponse> CompleteAsync(string model, IReadOnlyList<ChatMessage> messages,
IReadOnlyList<SupervisorTool> 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<ChatMessage> messages, IReadOnlyList<SupervisorTool> 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<ToolCall>();
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] + "…";
}
}
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PolyTrader.Modules.Supervisor.Agent
{
/// <summary>Ergebnis einer Agenten-Anfrage inkl. transparenter Tool-Aufruf-Historie.</summary>
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; }
}
/// <summary>
/// Der Analyse-Agent (S-2): Function-Calling-Loop gegen einen <see cref="IChatCompletionClient"/>
/// mit der read-only <see cref="SupervisorToolRegistry"/>. System-Kontext = Arbeitsanweisung +
/// Architektur-Dokument. Harte Iterationsgrenze gegen Endlosschleifen; jeder Tool-Aufruf wird
/// festgehalten (Nachvollziehbarkeit in der UI).
/// </summary>
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();
/// <summary>
/// Beantwortet eine Analyse-Frage. <paramref name="progress"/> meldet Tool-Aufrufe live an die UI.
/// </summary>
public async Task<AgentResult> AskAsync(string question, string? model = null,
IProgress<string>? progress = null, CancellationToken ct = default)
{
var messages = new List<ChatMessage>
{
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
};
}
}
}
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace PolyTrader.Modules.Supervisor.Agent
{
/// <summary>
/// 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.
/// </summary>
public sealed record SupervisorTool(
string Name,
string Description,
string ParametersJsonSchema,
Func<JsonElement, string> Execute);
/// <summary>
/// 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.
/// </summary>
public sealed class SupervisorToolRegistry
{
private readonly Dictionary<string, SupervisorTool> _tools = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<SupervisorTool> 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;
}
}
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<LogJson.ParsedLogLine>();
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;
}
}
}
@@ -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.900.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.
@@ -11,6 +11,11 @@
</AssemblyAttribute>
</ItemGroup>
<!-- Architektur-Kontext des Agenten: versioniert mit dem Code, zur Laufzeit aus der Assembly geladen. -->
<ItemGroup>
<EmbeddedResource Include="Context\ArchitectureContext.md" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -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<DossierService>();
// 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<PolyTrader.Core.Persistence.IDecisionJournal>(),
sp.GetRequiredService<PolyTrader.Core.Persistence.IOrderEventLog>(),
sp.GetRequiredService<PolyTrader.Core.Persistence.ITradeLogRepository>(),
sp.GetRequiredService<DossierService>()));
services.AddSingleton<Agent.IChatCompletionClient>(_ =>
new Agent.OpenRouterClient(new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMinutes(3) }));
services.AddSingleton<Agent.SupervisorAgent>();
// sup_-Persistenz (Berichte/Konversationen) + Profile folgen mit S-3.
}
public void RegisterUi(IModuleUiHost host, System.IServiceProvider services)
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<DossierService>();
_agent = services.GetRequiredService<SupervisorAgent>();
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<string>(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;
@@ -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
{
/// <summary>
/// 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).
/// </summary>
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<ChatResponse> _script;
public List<IReadOnlyList<ChatMessage>> Requests { get; } = new();
public ScriptedChatClient(params ChatResponse[] script) => _script = new Queue<ChatResponse>(script);
public Task<ChatResponse> CompleteAsync(string model, IReadOnlyList<ChatMessage> messages,
IReadOnlyList<SupervisorTool> tools, CancellationToken ct)
{
Requests.Add(new List<ChatMessage>(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>
{
ChatMessage.System("sys"),
ChatMessage.User("frage"),
ChatMessage.Assistant(null, new List<ToolCall> { new("c1", "echo", """{"text":"x"}""") }),
ChatMessage.ToolResult("c1", "ergebnis")
};
var tools = new List<SupervisorTool>
{
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);
}
}
}