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(); // Read queue once for workload calculation var queueItems = new List(); try { queueItems = await GetQueueAsync(); } catch { } 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" }; } // 6. Read goal from workspace files var goal = await ReadAgentGoalAsync(id); // 7. Calculate progress dynamically var progress = CalculateAgentProgress(id, isActive, status); // 8. Calculate workload from queue items var workload = CalculateAgentWorkload(id, queueItems); 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, Progress: progress, Workload: workload, Goal: goal )); } 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; } /// /// Collects assistant messages from ALL agent sessions (multi-agent operations feed). /// Merges, sorts by timestamp descending, and limits the result. /// Falls back to an empty list if any agent session is unreachable. /// public async Task> GetAllAgentOperationsAsync(int limit = 30) { var allEntries = new List(); var agentIds = LoadAgentIdsFromConfig(); foreach (var agentId in agentIds) { try { var sessionKey = $"agent:{agentId}:main"; var messages = await GetSessionHistoryAsync(sessionKey, Math.Min(limit * 2, 50)); foreach (var msg in messages) { if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase)) continue; if (string.IsNullOrWhiteSpace(msg.Content)) continue; // Parse timestamp var ts = ParseTimestamp(msg.Timestamp); var timeAgo = FormatTimeAgo(ts); // Extract a short agent indicator and action from content var (agent, action) = ExtractAgentAction(msg.Content); // Determine event type based on content heuristics var eventType = DetectEventType(msg.Content); allEntries.Add(new FeedEntry( agent, action, msg.Timestamp, timeAgo, AgentId: agentId, Type: eventType )); } } catch { // Agent session unreachable — skip; we still have data from other agents } } // Sort descending by timestamp, then limit return allEntries .OrderByDescending(e => ParseTimestamp(e.Timestamp)) .Take(Math.Clamp(limit, 1, 100)) .ToList(); } private static DateTimeOffset ParseTimestamp(string timestamp) { if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt)) return dt; return DateTimeOffset.UtcNow; } private static string FormatTimeAgo(DateTimeOffset ts) { var diff = DateTimeOffset.UtcNow - ts; if (diff.TotalMinutes < 1) return "just now"; if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; return ts.ToString("MMM dd"); } /// /// Determines a FeedEntry event type from message content heuristics. /// private static string DetectEventType(string content) { if (content.Contains("Subagent Task") || content.Contains("subagent")) { if (content.Contains("complete") || content.Contains("done") || content.Contains("finished")) return "task_complete"; return "task_start"; } if (content.Contains("Deploy") || content.Contains("deploy") || content.Contains("publish")) return "deploy"; if (content.Contains("System") || content.Contains("system") || content.Contains("health")) return "system"; if (content.Contains("Gestartet") || content.Contains("started") || content.Contains("Session")) return "session_start"; return "chat"; } /// /// Extracts a human-readable agent name and action summary from message content. /// private static (string Agent, string Action) ExtractAgentAction(string content) { // Take first line or first ~80 chars as the action summary var firstLine = content.Split('\n', 2)[0].Trim(); var summary = firstLine.Length > 80 ? firstLine[..80] + "\u2026" : firstLine; // Try to identify which agent this came from var agent = "Iris"; foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" }) { var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (idx >= 0) { var after = content[(idx + marker.Length)..].TrimStart(); var end = after.IndexOfAny(['\n', '\r', ',', '.']); var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim(); if (!string.IsNullOrWhiteSpace(found) && found.Length < 30) { agent = found; break; } } } // Try to find agent name at the start in brackets like [Agent: Iris] if (agent == "Iris") { var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]"); if (bracketMatch.Success) agent = bracketMatch.Groups[1].Value.Trim(); } return (agent, summary); } 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"; // Calculate waitTime from nextRun if available var waitTime = "--"; var nextRunStr = j["nextRun"]?.GetValue() ?? j["next_run"]?.GetValue() ?? j["scheduledAt"]?.GetValue(); if (nextRunStr is not null && DateTimeOffset.TryParse(nextRunStr, out var nextRun)) { var diff = nextRun - DateTimeOffset.UtcNow; if (diff.TotalMinutes < 0) waitTime = "now"; else if (diff.TotalMinutes < 1) waitTime = "<1m"; else if (diff.TotalMinutes < 60) waitTime = $"{(int)diff.TotalMinutes}m"; else if (diff.TotalHours < 24) waitTime = $"{(int)diff.TotalHours}h"; else waitTime = $"{(int)diff.TotalDays}d"; } items.Add(new QueueItem(id, name, status, "medium", "cron", waitTime)); } return items; } catch { return new List(); } } public async Task DeleteCronJobAsync(string id) { try { var result = await InvokeToolAsync("cron", new { action = "delete", id }); return result is not null; } catch { return false; } } 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; } } /// /// Reads the agent's goal from workspace files (goals.md preferred, then AGENTS.md, SOUL.md). /// Returns the first meaningful line or null. /// private async Task ReadAgentGoalAsync(string agentId) { try { var workspacePath = "/home/node/.openclaw/workspace-" + agentId; // 1. Try goals.md first var goalsPath = Path.Combine(workspacePath, "goals.md"); if (System.IO.File.Exists(goalsPath)) { var content = await System.IO.File.ReadAllTextAsync(goalsPath); foreach (var line in content.Split('\n')) { var trimmed = line.Trim(); if (!string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('#') && !trimmed.StartsWith('-')) return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed; } } // 2. Try SOUL.md "## Rolle" section var soulPath = Path.Combine(workspacePath, "SOUL.md"); if (System.IO.File.Exists(soulPath)) { var soul = await System.IO.File.ReadAllTextAsync(soulPath); using var reader = new StringReader(soul); string? line; var inRoleSection = false; while ((line = reader.ReadLine()) is not null) { var trimmed = line.Trim(); if (trimmed.StartsWith("## ", StringComparison.OrdinalIgnoreCase)) { if (inRoleSection) break; if (trimmed.IndexOf("Rolle", StringComparison.OrdinalIgnoreCase) >= 0 || trimmed.IndexOf("Role", StringComparison.OrdinalIgnoreCase) >= 0 || trimmed.IndexOf("Oberstes", StringComparison.OrdinalIgnoreCase) >= 0) { inRoleSection = true; } continue; } if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('-')) { return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed; } } // 3. Look for "## Oberstes Prinzip" as second choice inRoleSection = false; reader = new StringReader(soul); while ((line = reader.ReadLine()) is not null) { var trimmed = line.Trim(); if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0) { inRoleSection = true; continue; } if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('-') && !trimmed.StartsWith('#')) { return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed; } } } // 4. Try AGENTS.md first heading var agentsPath = Path.Combine(workspacePath, "AGENTS.md"); if (System.IO.File.Exists(agentsPath)) { var agentsContent = await System.IO.File.ReadAllTextAsync(agentsPath); foreach (var line in agentsContent.Split('\n')) { var trimmed = line.Trim(); if (trimmed.StartsWith("# ")) return trimmed[2..].Trim(); } } } catch { // Fallback to hardcoded } // Fallback: known goals return agentId.ToLowerInvariant() switch { "iris" => "Mission Control — maximale Autonomie bei kontrolliertem Risiko", "programmer" => "Nexus Dashboard & Dungeon System", "reviewer" => "Zero critical findings before merge", "architekt" => "Stabile Zero-Downtime-Deployments", "executor" => "Sichere Host-Execution im Allowlist-Rahmen", "researcher" => "Verifizierte, strukturierte Recherche-Ergebnisse", _ => null }; } /// /// Calculates agent progress (0-100) based on session activity. /// Active agents with recent activity get higher scores. /// Idle agents or inactive sessions get lower scores. /// private static int CalculateAgentProgress(string agentId, bool isActive, JsonNode? status) { if (!isActive) return 0; // Base progress from active status var progress = 50; // Boost for agents that have a current task var hasTask = status?["currentTask"]?.GetValue() is not null || status?["task"]?.GetValue() is not null; if (hasTask) progress += 20; // Check for last activity timestamp — more recent = higher progress var lastActivity = status?["lastActivity"]?.GetValue() ?? status?["lastMessage"]?.GetValue(); if (lastActivity is not null && DateTimeOffset.TryParse(lastActivity, out var lastTs)) { var minutesSinceActivity = (DateTimeOffset.UtcNow - lastTs).TotalMinutes; if (minutesSinceActivity < 1) progress += 25; // actively working else if (minutesSinceActivity < 5) progress += 15; else if (minutesSinceActivity < 15) progress += 10; else progress -= 10; // stale } else if (hasTask) { // Has task but no timestamp — assume actively working progress += 15; } return Math.Clamp(progress, 0, 100); } /// /// Calculates agent workload (0-100) based on queue items and active status. /// More queued tasks or active sessions = higher workload. /// private static int CalculateAgentWorkload(string agentId, List queueItems) { if (queueItems.Count == 0) return 0; // Calculate workload based on queue density per agent var agentQueued = queueItems.Count(q => q.Name.Contains(agentId, StringComparison.OrdinalIgnoreCase) || q.Id.Contains(agentId, StringComparison.OrdinalIgnoreCase)); // Base workload from total queue pressure var totalQueuePressure = Math.Min(queueItems.Count * 10, 60); // Agent-specific queue items add extra weight var agentPressure = agentQueued * 25; return Math.Clamp(totalQueuePressure + agentPressure, 0, 100); } /// /// Fetches the most recent assistant activity (last N messages) for a specific agent. /// Returns entries with timestamp and truncated content text. /// Falls back to an empty list if the session is unreachable. /// public async Task> GetAgentActivityAsync(string agentId, int limit = 5) { var entries = new List(); try { var sessionKey = $"agent:{agentId}:main"; var messages = await GetSessionHistoryAsync(sessionKey, Math.Clamp(limit * 2, 1, 100)); foreach (var msg in messages) { if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase)) continue; if (string.IsNullOrWhiteSpace(msg.Content)) continue; if (msg.Content == "REPLY_SKIP" || msg.Content == "ANNOUNCE_SKIP") continue; // Truncate content to first 200 chars for compact display var text = msg.Content.Length > 200 ? msg.Content[..200] + "…" : msg.Content; var ts = ParseTimestamp(msg.Timestamp); var timeAgo = FormatTimeAgo(ts); entries.Add(new AgentActivityEntry(timeAgo, text)); } } catch { // Return empty list if gateway is unreachable } return entries.Take(Math.Clamp(limit, 1, 20)).ToList(); } /// /// Returns the list of available models by reading from the OpenClaw config, /// with fallback to hardcoded list. /// public List GetAvailableModels() { try { var configPath = configuration.GetValue("AgentConfigPath") ?? "/home/node/.openclaw/openclaw.json"; if (!System.IO.File.Exists(configPath)) return GetDefaultModels(); var json = System.IO.File.ReadAllText(configPath); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; // Read models from agents.defaults.models if (!root.TryGetProperty("agents", out var agentsEl)) return GetDefaultModels(); if (!agentsEl.TryGetProperty("defaults", out var defaultsEl)) return GetDefaultModels(); if (!defaultsEl.TryGetProperty("models", out var modelsEl)) return GetDefaultModels(); var models = new List(); foreach (var modelProp in modelsEl.EnumerateObject()) { var modelId = modelProp.Name; var modelObj = modelProp.Value; var name = modelId; // fallback: use model ID as name var provider = ExtractProvider(modelId); // Check for alias in the model object if (modelObj.TryGetProperty("alias", out var aliasEl)) { var alias = aliasEl.GetString(); if (!string.IsNullOrWhiteSpace(alias)) name = alias; } models.Add(new ModelOption(modelId, name, provider)); } return models.Count > 0 ? models : GetDefaultModels(); } catch { return GetDefaultModels(); } } private static List GetDefaultModels() => new() { new ModelOption("openai/gpt-5.4", "GPT-5.4", "openai"), new ModelOption("openai/gpt-5.5", "GPT-5.5", "openai"), new ModelOption("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", "deepseek"), new ModelOption("deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", "deepseek") }; private static string ExtractProvider(string modelId) { var slash = modelId.IndexOf('/'); return slash > 0 ? modelId[..slash] : "unknown"; } 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" }; }