From 6a1366b4723ebfed6b0b74e430cb3d487d978b69 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 11 Jun 2026 15:48:29 +0200 Subject: [PATCH] feat(dashboard): dynamic agent data from gateway sessions instead of hardcoded list --- backend/Services/OpenClawGatewayClient.cs | 361 ++++++++++++++++++---- 1 file changed, 303 insertions(+), 58 deletions(-) diff --git a/backend/Services/OpenClawGatewayClient.cs b/backend/Services/OpenClawGatewayClient.cs index 4167354..453dd35 100644 --- a/backend/Services/OpenClawGatewayClient.cs +++ b/backend/Services/OpenClawGatewayClient.cs @@ -61,84 +61,329 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration } } + /// + /// 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() { - var agentDefs = new[] + // Fallback hardcoded descriptions and tags known for each agent + var knownInfo = new Dictionary { - new { Id = "iris", Name = "Chief of Staff", Model = "deepseek/deepseek-v4-flash", - Description = "Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.", - Tags = new[] { "Orchestration", "Delegation", "Approval", "Risk Management" } }, - new { Id = "programmer", Name = "Full-Stack Developer", Model = "deepseek/deepseek-v4-flash", - Description = "Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.", - Tags = new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" } }, - new { Id = "reviewer", Name = "Code Quality Assurance", Model = "deepseek/deepseek-v4-pro", - Description = "Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.", - Tags = new[] { "Code Review", "Testing", "Security", "Quality" } }, - new { Id = "architekt", Name = "Infrastructure Architect", Model = "deepseek/deepseek-v4-pro", - Description = "Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.", - Tags = new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" } }, - new { Id = "executor", Name = "Host Executor", Model = "deepseek/deepseek-v4-flash", - Description = "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.", - Tags = new[] { "Docker", "Shell", "Host", "Deployment" } }, - new { Id = "researcher", Name = "Research & Analysis", Model = "deepseek/deepseek-v4-pro", - Description = "Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.", - Tags = new[] { "Research", "Quellenprüfung", "Analyse", "Docs" } }, + ["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 def in agentDefs) + 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; - try + if (status is not null) { - var memDir = "/mnt/workspace-" + def.Id + "/memory"; - if (Directory.Exists(memDir)) - { - var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories) - .Select(f => new FileInfo(f)) - .OrderByDescending(f => f.LastWriteTimeUtc) - .FirstOrDefault(); - if (latestFile is not null) - { - var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc; - isActive = age.TotalMinutes < 15; - if (isActive) - { - try - { - var firstLine = File.ReadLines(latestFile.FullName).FirstOrDefault()?.Trim(); - if (!string.IsNullOrWhiteSpace(firstLine) && firstLine.Length > 60) - currentTask = firstLine[..60]; - else if (!string.IsNullOrWhiteSpace(firstLine)) - currentTask = firstLine; - else - currentTask = "Working..."; - } - catch - { - currentTask = "Working..."; - } - } - } - } + // 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" + }; } - catch { } agents.Add(new DashboardAgentInfo( - Id: def.Id, - Name: def.Name, - Role: DeriveRole(def.Id), - Model: def.Model, + Id: id, + Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name, + Role: DeriveRole(id), + Model: model, IsActive: isActive, CurrentTask: currentTask, - Description: def.Description, - Tags: def.Tags + 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) {