using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; using Nexus.Api.Models; namespace Nexus.Api.Services; public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private string? GetPassword() { var password = configuration["Integrations:OpenClaw:Password"]; if (string.IsNullOrWhiteSpace(password)) password = configuration["Integrations:OpenClaw:Token"]; return string.IsNullOrWhiteSpace(password) ? null : password; } private void ApplyAuth(HttpRequestMessage request) { var password = GetPassword(); if (password is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", password); } public async Task InvokeToolAsync(string tool, object? args = null) { try { using var request = new HttpRequestMessage(HttpMethod.Post, "/tools/invoke"); ApplyAuth(request); var body = new Dictionary { ["tool"] = tool }; if (args is not null) body["args"] = args; request.Content = JsonContent.Create(body); using var response = await httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(json)) return null; var node = JsonNode.Parse(json); if (node?["ok"]?.GetValue() == true && node["result"] is not null) return node["result"]; return node; } catch { return null; } } /// /// Reads the agent session status from the Gateway via session_status tool. /// Returns fields like model, provider, status, lastActivity, isActive, currentTask. /// Returns null if the session is unreachable. /// private async Task TryGetAgentStatusAsync(string agentId) { try { return await InvokeToolAsync("session_status", new { sessionKey = "agent:" + agentId + ":main" }); } catch { return null; } } public async Task> GetAgentsAsync() { // Fallback hardcoded descriptions and tags known for each agent var knownInfo = new Dictionary { ["iris"] = ( "Iris", "Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.", new[] { "Orchestration", "Delegation", "Approval", "Risk Management" } ), ["programmer"] = ( "Full-Stack Developer", "Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.", new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" } ), ["reviewer"] = ( "Code Quality Assurance", "Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.", new[] { "Code Review", "Testing", "Security", "Quality" } ), ["architekt"] = ( "Infrastructure Architect", "Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.", new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" } ), ["executor"] = ( "Host Executor", "Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.", new[] { "Docker", "Shell", "Host", "Deployment" } ), ["researcher"] = ( "Research & Analysis", "Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.", new[] { "Research", "Quellenprüfung", "Analyse", "Docs" } ) }; // Load agent IDs from openclaw.json config var agentIds = LoadAgentIdsFromConfig(); var agents = new List(); foreach (var id in agentIds) { // Skip the "main" agent (it's the default assistant, not a sub-agent) if (string.Equals(id, "main", StringComparison.OrdinalIgnoreCase)) continue; // 1. Try to get dynamic session status from Gateway var status = await TryGetAgentStatusAsync(id); // 2. Extract model from session_status (dynamic) var model = status?["model"]?.GetValue(); // 3. Extract activity from session_status var isActive = false; string? currentTask = null; if (status is not null) { // Check explicit isActive field var activeVal = status["isActive"]; if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.True) isActive = true; else if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.String) isActive = string.Equals(activeVal.GetValue(), "true", StringComparison.OrdinalIgnoreCase); // Fall back to status text var statusText = status["status"]?.GetValue(); if (!isActive && statusText is not null) isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase) || string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase); currentTask = status["currentTask"]?.GetValue() ?? status["task"]?.GetValue() ?? (isActive ? "Working..." : null); } // 4. Try to read workspace metadata for richer info var (name, description, tags) = await ReadAgentMetadataAsync(id); // 5. Fallback to known info if workspace metadata not available if (string.IsNullOrWhiteSpace(name) && knownInfo.TryGetValue(id, out var kn)) { name = kn.Name; if (string.IsNullOrWhiteSpace(description)) description = kn.Description; if (tags.Length == 0) tags = kn.Tags; } if (string.IsNullOrWhiteSpace(model)) { model = id.ToLowerInvariant() switch { "iris" or "programmer" or "executor" => "deepseek/deepseek-v4-flash", "reviewer" or "architekt" or "researcher" => "deepseek/deepseek-v4-pro", _ => "deepseek/deepseek-v4-flash" }; } agents.Add(new DashboardAgentInfo( Id: id, Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name, Role: DeriveRole(id), Model: model, IsActive: isActive, CurrentTask: currentTask, Description: description, Tags: tags )); } return agents; } /// /// Loads agent IDs from the OpenClaw config file (openclaw.json). /// Falls back to the known list if the config file is unavailable. /// private List LoadAgentIdsFromConfig() { try { var configPath = configuration.GetValue("AgentConfigPath") ?? "/home/node/.openclaw/openclaw.json"; if (!System.IO.File.Exists(configPath)) return GetDefaultAgentIds(); var json = System.IO.File.ReadAllText(configPath); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (!root.TryGetProperty("agents", out var agentsEl)) return GetDefaultAgentIds(); if (!agentsEl.TryGetProperty("list", out var listEl)) return GetDefaultAgentIds(); var ids = new List(); foreach (var agentEl in listEl.EnumerateArray()) { if (agentEl.TryGetProperty("id", out var idEl)) { var id = idEl.GetString(); if (!string.IsNullOrWhiteSpace(id)) ids.Add(id); } } return ids.Count > 0 ? ids : GetDefaultAgentIds(); } catch { return GetDefaultAgentIds(); } } private static List GetDefaultAgentIds() => new() { "iris", "programmer", "reviewer", "architekt", "executor", "researcher" }; /// /// Reads agent metadata from workspace files (IDENTITY.md, SOUL.md). /// Returns (Name, Description, Tags) — empty strings/arrays if unavailable. /// Tags are not read from files (kept as empty for dynamic agents). /// private async Task<(string Name, string Description, string[] Tags)> ReadAgentMetadataAsync(string agentId) { var name = string.Empty; var description = string.Empty; var tags = Array.Empty(); try { // Try the host-mounted workspace path (used by the API container) var workspacePath = "/mnt/workspace-" + agentId; var identityPath = Path.Combine(workspacePath, "IDENTITY.md"); if (System.IO.File.Exists(identityPath)) { var content = await System.IO.File.ReadAllTextAsync(identityPath); ParseIdentityContent(content, out name, out description); } else { // Fallback: try the OpenClaw workspace path inside the gateway container var altPath = "/home/node/.openclaw/workspace-" + agentId + "/IDENTITY.md"; if (System.IO.File.Exists(altPath)) { var content = await System.IO.File.ReadAllTextAsync(altPath); ParseIdentityContent(content, out name, out description); } } // If description is still empty, try SOUL.md if (string.IsNullOrWhiteSpace(description)) { var soulPath = Path.Combine(workspacePath, "SOUL.md"); string? soulContent = null; if (System.IO.File.Exists(soulPath)) { soulContent = await System.IO.File.ReadAllTextAsync(soulPath); } else { var altSoulPath = "/home/node/.openclaw/workspace-" + agentId + "/SOUL.md"; if (System.IO.File.Exists(altSoulPath)) { soulContent = await System.IO.File.ReadAllTextAsync(altSoulPath); } } if (soulContent is not null) { description = ExtractDescriptionFromSoul(soulContent); } } } catch { // Fallback to hardcoded values will handle this } return (name, description, tags); } /// /// Parses an IDENTITY.md file to extract Name and a short description (role/creature). /// Looks for markdown list items like "- **Name:** ...", "- **Rolle:** ...", etc. /// private static void ParseIdentityContent(string content, out string name, out string description) { name = string.Empty; description = string.Empty; using var reader = new StringReader(content); string? line; while ((line = reader.ReadLine()) is not null) { // Extract name from "- **Name:** ..." if (string.IsNullOrWhiteSpace(name)) { var nameMarker = "- **Name:**"; var idx = line.IndexOf(nameMarker, StringComparison.OrdinalIgnoreCase); if (idx >= 0) { name = line[(idx + nameMarker.Length)..].Trim(); continue; } } // Extract role/theme from "- **Rolle:** ..." or "- **Role:** ..." or "- **Creature:** ..." if (string.IsNullOrWhiteSpace(description)) { var descMarkers = new[] { "- **Rolle:**", "- **Role:**", "- **Creature:**" }; foreach (var marker in descMarkers) { var idx = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (idx >= 0) { description = line[(idx + marker.Length)..].Trim(); break; } } } } } /// /// Extracts a short description from SOUL.md (first heading or first paragraph after the "Rolle" section). /// Returns the first ~200 characters of meaningful content. /// private static string ExtractDescriptionFromSoul(string content) { // Look for "## Rolle" section and take the first paragraph after it using var reader = new StringReader(content); string? line; var inRoleSection = false; var paragraphs = new List(); while ((line = reader.ReadLine()) is not null) { var trimmed = line.Trim(); if (trimmed.StartsWith("## ", StringComparison.OrdinalIgnoreCase)) { if (inRoleSection) break; // We've moved past the "Rolle" section if (trimmed.IndexOf("Rolle", StringComparison.OrdinalIgnoreCase) >= 0 || trimmed.IndexOf("Role", StringComparison.OrdinalIgnoreCase) >= 0) { inRoleSection = true; } continue; } if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed)) { paragraphs.Add(trimmed); if (paragraphs.Count >= 3) break; } } var summary = string.Join(" ", paragraphs); return summary.Length > 200 ? summary[..200] + "…" : summary; } public async Task> GetSessionHistoryAsync( string sessionKey, int limit = 50, int offset = 0) { var result = new List(); try { var toolResult = await InvokeToolAsync("sessions_history", new { sessionKey, limit, offset, includeTools = false }); if (toolResult is null) return result; var json = toolResult.ToJsonString(); result.Add(new MessageEntry("diag", "JSON[" + json.Substring(0, Math.Min(200, json.Length)) + "]", DateTimeOffset.UtcNow.ToString("o"))); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (!root.TryGetProperty("details", out var detailsEl)) return result; if (!detailsEl.TryGetProperty("messages", out var messagesEl)) return result; if (messagesEl.ValueKind != JsonValueKind.Array) return result; foreach (var msg in messagesEl.EnumerateArray()) { if (!msg.TryGetProperty("role", out var roleEl)) continue; var role = roleEl.GetString() ?? ""; if (role != "user" && role != "assistant") continue; if (!msg.TryGetProperty("content", out var contentEl)) continue; if (contentEl.ValueKind != JsonValueKind.Array) continue; var texts = new List(); foreach (var block in contentEl.EnumerateArray()) { if (!block.TryGetProperty("type", out var typeEl)) continue; if (typeEl.GetString() != "text") continue; if (!block.TryGetProperty("text", out var textEl)) continue; var text = textEl.GetString(); if (!string.IsNullOrWhiteSpace(text)) texts.Add(text); } if (texts.Count == 0) continue; var content = string.Join(" ", texts).Trim(); if (string.IsNullOrWhiteSpace(content)) continue; if (content == "REPLY_SKIP" || content == "ANNOUNCE_SKIP") continue; var ts = DateTimeOffset.UtcNow.ToString("o"); if (msg.TryGetProperty("timestamp", out var tsEl)) ts = tsEl.GetString() ?? ts; result.Add(new MessageEntry(role, content, ts)); } } catch { // return whatever we collected (may be empty) } return result; } public async Task SendChatMessageAsync(string agentId, string message) { try { var result = await InvokeToolAsync("sessions_send", new { agentId, message }); if (result is null) return new ChatResponse(false, null, "Gateway nicht erreichbar"); var details = result["details"]; var ok = (details?["status"]?.GetValue() ?? result["status"]?.GetValue()) == "ok"; var reply = details?["reply"]?.GetValue() ?? result["reply"]?.GetValue() ?? result["response"]?.GetValue() ?? result["content"]?[0]?["text"]?.GetValue(); var error = details?["error"]?.GetValue() ?? result["error"]?.GetValue(); return new ChatResponse(ok, reply, error); } catch (Exception ex) { return new ChatResponse(false, null, "Fehler: " + ex.Message); } } public async Task> GetQueueAsync() { try { var result = await InvokeToolAsync("cron", new { action = "list" }); if (result is null) return new List(); var details = result["details"]; var jobs = details?["jobs"] as JsonArray ?? result.AsArray(); if (jobs is null) return new List(); var items = new List(); foreach (var j in jobs) { if (j is null) continue; var id = j["id"]?.GetValue() ?? ""; var name = j["name"]?.GetValue() ?? id; var status = j["state"]?["lastStatus"]?.GetValue() ?? j["status"]?.GetValue() ?? "unknown"; items.Add(new QueueItem(id, name, status)); } return items; } catch { return new List(); } } public async Task GetStatusAsync() { var gatewayOk = false; var irisStatus = "Offline"; var activeAgents = 0; var pendingTasks = 0; try { using var pingRequest = new HttpRequestMessage(HttpMethod.Get, "/health"); using var pingResponse = await httpClient.SendAsync(pingRequest); gatewayOk = pingResponse.IsSuccessStatusCode; } catch { } if (gatewayOk) { try { var r = await InvokeToolAsync("session_status"); if (r is not null) irisStatus = r["status"]?.GetValue() ?? r["sessionKey"]?.GetValue() ?? "Active"; } catch { } try { var a = await GetAgentsAsync(); activeAgents = a.Count(x => x.IsActive); } catch { } try { var q = await GetQueueAsync(); pendingTasks = q.Count; } catch { } } return new DashboardStatus(gatewayOk, irisStatus, activeAgents, pendingTasks); } public async Task GetAgentModelAsync(string agentId) { try { var result = await InvokeToolAsync("session_status", new { sessionKey = $"agent:{agentId}:main" }); if (result is null) return null; var model = result["model"]?.GetValue(); var provider = result["provider"]?.GetValue(); if (model is null) return null; return new AgentModelInfo(model, provider ?? "unknown"); } catch { return null; } } public async Task SetAgentModelAsync(string agentId, string model) { try { var result = await InvokeToolAsync("session_status", new { sessionKey = $"agent:{agentId}:main", model }); return result is not null; } catch { return false; } } private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch { "iris" => "Chief of Staff", "programmer" => "Full-Stack Developer", "reviewer" => "Code Quality Assurance", "architekt" => "Infrastructure Architect", "executor" => "Host Executor", "researcher" => "Research & Analysis", "main" => "Assistant", _ => "Custom" }; }