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:
co-authored by
Claude Opus 4.8
parent
c75a958e36
commit
5d732277b2
@@ -31,6 +31,7 @@ MongoDB/
|
|||||||
# Gitea Personal Access Token für Pushes – niemals committen
|
# Gitea Personal Access Token für Pushes – niemals committen
|
||||||
.gitea-token
|
.gitea-token
|
||||||
master.key
|
master.key
|
||||||
|
openrouter.key
|
||||||
|
|
||||||
# ── Logs & temporäre Dateien ─────────────────────
|
# ── Logs & temporäre Dateien ─────────────────────
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ gefunden. Ausgehende Verbindungen (Code-Audit der URLs):
|
|||||||
gebroadcastete Transaktionen.
|
gebroadcastete Transaktionen.
|
||||||
- **Eigene MySQL-DB** (remote gehostet) — eigene Infrastruktur, kein Dritt-Dienst im engeren Sinn.
|
- **Eigene MySQL-DB** (remote gehostet) — eigene Infrastruktur, kein Dritt-Dienst im engeren Sinn.
|
||||||
- **Mullvad VPN** — bewusstes Datenschutz-Tool (Routing).
|
- **Mullvad VPN** — bewusstes Datenschutz-Tool (Routing).
|
||||||
- **Threema Gateway** (`msgapi.threema.ch`) — **einziger Nicht-Polymarket-Dienst, der App-Inhalte
|
- **Threema Gateway** (`msgapi.threema.ch`) — App-Inhalte: unsere Benachrichtigungstexte.
|
||||||
empfängt** (unsere Benachrichtigungstexte). Bewusstes Feature; end-to-end-verschlüsselt.
|
Bewusstes Feature; end-to-end-verschlüsselt.
|
||||||
→ **Keine versteckte Datenweitergabe.** OpenRouter/AI-Anbindung existiert nur im separaten
|
- **OpenRouter** (`openrouter.ai`, seit S-2 Supervisor-Modul) — **bewusst freigegebener** externer
|
||||||
Predictalytics-Projekt, **nicht** in PolyTrader.
|
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.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.
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
</AssemblyAttribute>
|
</AssemblyAttribute>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Architektur-Kontext des Agenten: versioniert mit dem Code, zur Laufzeit aus der Assembly geladen. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Context\ArchitectureContext.md" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
@@ -20,9 +20,21 @@ namespace PolyTrader.Modules.Supervisor
|
|||||||
|
|
||||||
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
|
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// S-1: reine Beschaffung/Aufbereitung. Eigene sup_-Persistenz (Berichte/Konversationen)
|
// S-1: Dossier-Beschaffung/-Aufbereitung.
|
||||||
// folgt mit S-2/S-3.
|
|
||||||
services.AddSingleton<DossierService>();
|
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)
|
public void RegisterUi(IModuleUiHost host, System.IServiceProvider services)
|
||||||
|
|||||||
@@ -3,26 +3,44 @@ using System.Collections.Generic;
|
|||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PolyTrader.Core.Analytics;
|
using PolyTrader.Core.Analytics;
|
||||||
|
using PolyTrader.Modules.Supervisor.Agent;
|
||||||
using PolyTrader.Modules.Supervisor.Services;
|
using PolyTrader.Modules.Supervisor.Services;
|
||||||
|
|
||||||
namespace PolyTrader.Modules.Supervisor.Ui
|
namespace PolyTrader.Modules.Supervisor.Ui
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hauptfenster des Supervisor-Moduls (S-1): Dossier-Browser — links die jüngsten Signale
|
/// Hauptfenster des Supervisor-Moduls: Tab „Analyse" (Chat mit dem read-only-Agenten,
|
||||||
/// (aus dem Entscheidungsjournal), rechts das komplette Dossier (Markdown). Der Analyse-Chat
|
/// Tool-Aufrufe transparent im Verlauf) und Tab „Dossiers" (Signal-Browser mit Markdown-Dossier).
|
||||||
/// (OpenRouter-Agent) folgt in S-2 als weiterer Tab. Code-only konstruiert (Muster RF-Modul);
|
/// Code-only konstruiert (Muster RF-Modul); DB-/API-Zugriffe defensiv.
|
||||||
/// DB-Zugriffe defensiv, damit die UI auch bei leerer/nicht erreichbarer DB bedienbar bleibt.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SupervisorMainForm : Form
|
public sealed class SupervisorMainForm : Form
|
||||||
{
|
{
|
||||||
private DossierService? _dossiers;
|
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 ToolStripButton _btnRefresh = new() { Text = "Aktualisieren", DisplayStyle = ToolStripItemDisplayStyle.Text };
|
||||||
private readonly ToolStripLabel _lblSearch = new() { Text = "SignalId:" };
|
private readonly ToolStripLabel _lblSearch = new() { Text = "SignalId:" };
|
||||||
private readonly ToolStripTextBox _tbSignalId = new() { AutoSize = false, Width = 220 };
|
private readonly ToolStripTextBox _tbSignalId = new() { AutoSize = false, Width = 220 };
|
||||||
private readonly ToolStripButton _btnOpen = new() { Text = "Dossier öffnen", DisplayStyle = ToolStripItemDisplayStyle.Text };
|
private readonly ToolStripButton _btnOpen = new() { Text = "Dossier öffnen", DisplayStyle = ToolStripItemDisplayStyle.Text };
|
||||||
|
|
||||||
private readonly SplitContainer _split = new() { Dock = DockStyle.Fill, SplitterDistance = 420 };
|
private readonly SplitContainer _split = new() { Dock = DockStyle.Fill, SplitterDistance = 420 };
|
||||||
private readonly DataGridView _grid = new()
|
private readonly DataGridView _grid = new()
|
||||||
{
|
{
|
||||||
@@ -35,24 +53,49 @@ namespace PolyTrader.Modules.Supervisor.Ui
|
|||||||
Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both,
|
Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both,
|
||||||
Font = new System.Drawing.Font("Consolas", 9.5f), WordWrap = false
|
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 = "" };
|
private readonly Label _status = new() { Dock = DockStyle.Bottom, Height = 22, Padding = new Padding(6, 2, 6, 2), Text = "" };
|
||||||
|
|
||||||
public SupervisorMainForm()
|
public SupervisorMainForm()
|
||||||
{
|
{
|
||||||
Text = "Supervisor";
|
Text = "Supervisor";
|
||||||
Width = 1250;
|
Width = 1250;
|
||||||
Height = 700;
|
Height = 720;
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
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.Panel1.Controls.Add(_grid);
|
||||||
_split.Panel2.Controls.Add(_dossierText);
|
_split.Panel2.Controls.Add(_dossierText);
|
||||||
|
tabDossiers.Controls.Add(_split);
|
||||||
|
tabDossiers.Controls.Add(_dossierStrip);
|
||||||
|
_dossierStrip.Dock = DockStyle.Top;
|
||||||
|
|
||||||
Controls.Add(_split);
|
tabs.TabPages.AddRange(new[] { tabAnalyse, tabDossiers });
|
||||||
Controls.Add(_toolStrip);
|
Controls.Add(tabs);
|
||||||
Controls.Add(_status);
|
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();
|
_btnRefresh.Click += (_, _) => LoadSignals();
|
||||||
_btnOpen.Click += (_, _) => OpenDossier(_tbSignalId.Text.Trim());
|
_btnOpen.Click += (_, _) => OpenDossier(_tbSignalId.Text.Trim());
|
||||||
_grid.SelectionChanged += (_, _) => OpenSelected();
|
_grid.SelectionChanged += (_, _) => OpenSelected();
|
||||||
@@ -61,9 +104,55 @@ namespace PolyTrader.Modules.Supervisor.Ui
|
|||||||
public void Initialize(IServiceProvider services)
|
public void Initialize(IServiceProvider services)
|
||||||
{
|
{
|
||||||
_dossiers = services.GetRequiredService<DossierService>();
|
_dossiers = services.GetRequiredService<DossierService>();
|
||||||
|
_agent = services.GetRequiredService<SupervisorAgent>();
|
||||||
LoadSignals();
|
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()
|
private void LoadSignals()
|
||||||
{
|
{
|
||||||
if (_dossiers == null) return;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user