using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using PolyTrader.Modules.Supervisor.Agent; using Xunit; namespace PolyTrader.Tests { /// /// Sicherheitsnetz für den S-2-Agenten: Tool-Registry (Ausführung, Fehler, Unbekanntes), /// Function-Calling-Loop (Tool-Ergebnisse fließen zurück, Iterationsgrenze) und die /// OpenRouter-Serialisierung (Request-Body pur, Response-Parsing). /// public class SupervisorAgentTests { // ----- Registry ----- private static SupervisorToolRegistry RegistryWithEcho() { var reg = new SupervisorToolRegistry(); reg.Register(new SupervisorTool("echo", "Echo", """{"type":"object","properties":{"text":{"type":"string"}}}""", args => "ECHO:" + (SupervisorToolRegistry.GetString(args, "text") ?? ""))); reg.Register(new SupervisorTool("boom", "wirft", """{"type":"object","properties":{}}""", _ => throw new InvalidOperationException("kaputt"))); return reg; } [Fact] public void Registry_executes_tool_with_args() { Assert.Equal("ECHO:hallo", RegistryWithEcho().Execute("echo", """{"text":"hallo"}""")); } [Fact] public void Registry_unknown_tool_and_broken_args_return_error_strings() { var reg = RegistryWithEcho(); Assert.StartsWith("FEHLER: Unbekanntes Tool", reg.Execute("gibtsnicht", "{}")); Assert.StartsWith("FEHLER: Ungültige Tool-Argumente", reg.Execute("echo", "{kein json")); Assert.StartsWith("FEHLER bei Tool 'boom'", reg.Execute("boom", "{}")); // Exception -> Text, wirft nie } // ----- Agent-Loop ----- private sealed class ScriptedChatClient : IChatCompletionClient { private readonly Queue _script; public List> Requests { get; } = new(); public List> OfferedTools { get; } = new(); public ScriptedChatClient(params ChatResponse[] script) => _script = new Queue(script); public Task CompleteAsync(string model, IReadOnlyList messages, IReadOnlyList tools, CancellationToken ct) { Requests.Add(new List(messages)); OfferedTools.Add(new List(tools)); 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); } [Fact] public async Task Agent_aborts_when_token_budget_exceeded() { // Erste Antwort verbraucht bereits mehr Tokens als das Budget -> Abbruch VOR Tool-Ausführung. var chat = new ScriptedChatClient( new ChatResponse { PromptTokens = 80, CompletionTokens = 80, ToolCalls = { new ToolCall("c1", "echo", "{}") } }); var agent = new SupervisorAgent(chat, RegistryWithEcho()); var profile = new SupervisorProfile("Knapp", "", null) { Budget = new PolyTrader.Core.Agents.AgentBudget { MaxTokens = 100, MaxSteps = 100 } }; var result = await agent.AskAsync("x", profile: profile); Assert.Contains("Token-Budget erschöpft", result.Answer); Assert.Empty(result.ToolInvocations); } // ----- OpenRouter-Serialisierung (pur) ----- [Fact] public void BuildRequestBody_produces_openai_compatible_json() { var messages = new List { ChatMessage.System("sys"), ChatMessage.User("frage"), ChatMessage.Assistant(null, new List { new("c1", "echo", """{"text":"x"}""") }), ChatMessage.ToolResult("c1", "ergebnis") }; var tools = new List { new("echo", "Echo-Tool", """{"type":"object","properties":{"text":{"type":"string"}}}""", _ => "") }; string body = OpenRouterClient.BuildRequestBody("openrouter/auto", messages, tools); using var doc = JsonDocument.Parse(body); // valides JSON var root = doc.RootElement; Assert.Equal("openrouter/auto", root.GetProperty("model").GetString()); var msgs = root.GetProperty("messages"); Assert.Equal(4, msgs.GetArrayLength()); Assert.Equal("function", msgs[2].GetProperty("tool_calls")[0].GetProperty("type").GetString()); Assert.Equal("echo", msgs[2].GetProperty("tool_calls")[0].GetProperty("function").GetProperty("name").GetString()); Assert.Equal("c1", msgs[3].GetProperty("tool_call_id").GetString()); Assert.Equal("echo", root.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString()); } [Fact] public void ParseResponse_reads_content_toolcalls_and_usage() { const string json = """ {"choices":[{"message":{"content":null,"tool_calls":[ {"id":"call_1","type":"function","function":{"name":"query_trades","arguments":"{\"limit\":5}"}}]}}], "usage":{"prompt_tokens":120,"completion_tokens":30}} """; var r = OpenRouterClient.ParseResponse(json); Assert.Null(r.Content); Assert.Single(r.ToolCalls); Assert.Equal("query_trades", r.ToolCalls[0].Name); Assert.Equal("""{"limit":5}""", r.ToolCalls[0].ArgumentsJson); Assert.Equal(120, r.PromptTokens); Assert.Equal(30, r.CompletionTokens); } [Fact] public void ArchitectureContext_is_embedded_and_loads() { string ctx = ArchitectureContext.Load(); Assert.Contains("PolyTrader", ctx); Assert.Contains("read-only", ctx); } // ----- Profile (S-3) ----- [Fact] public async Task Technik_profile_filters_tools_and_extends_prompt() { var reg = RegistryWithEcho(); reg.Register(new SupervisorTool("read_logs", "Logs", """{"type":"object","properties":{}}""", _ => "logs")); var chat = new ScriptedChatClient(new ChatResponse { Content = "ok" }); var agent = new SupervisorAgent(chat, reg); await agent.AskAsync("check", profile: SupervisorProfiles.Technik); // Prompt trägt den Profil-Fokus; dem Modell wurde NUR das Technik-Subset angeboten // (Registry hat echo/boom/read_logs → im Technik-Filter ist davon nur read_logs). Assert.Contains("FOKUS TECHNIK-SUPERVISOR", chat.Requests[0][0].Content); Assert.Single(chat.OfferedTools[0]); Assert.Equal("read_logs", chat.OfferedTools[0][0].Name); } [Fact] public void Profiles_lookup_is_case_insensitive_with_fallback() { Assert.Equal("Technik", SupervisorProfiles.ByName("technik").Name); Assert.Equal("Allgemein", SupervisorProfiles.ByName("gibtsnicht").Name); Assert.Equal(4, SupervisorProfiles.All.Count); } } }