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
@@ -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