Files
nexus/backend/Services/OpenClawGatewayClient.cs
T
developer 6a1366b472
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 2s
feat(dashboard): dynamic agent data from gateway sessions instead of hardcoded list
2026-06-11 15:48:33 +02:00

619 lines
22 KiB
C#

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<JsonNode?> InvokeToolAsync(string tool, object? args = null)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/tools/invoke");
ApplyAuth(request);
var body = new Dictionary<string, object?> { ["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<bool>() == true && node["result"] is not null)
return node["result"];
return node;
}
catch
{
return null;
}
}
/// <summary>
/// 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.
/// </summary>
private async Task<JsonNode?> TryGetAgentStatusAsync(string agentId)
{
try
{
return await InvokeToolAsync("session_status", new { sessionKey = "agent:" + agentId + ":main" });
}
catch
{
return null;
}
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
// Fallback hardcoded descriptions and tags known for each agent
var knownInfo = new Dictionary<string, (string Name, string Description, string[] Tags)>
{
["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<DashboardAgentInfo>();
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<string>();
// 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<string>(), "true", StringComparison.OrdinalIgnoreCase);
// Fall back to status text
var statusText = status["status"]?.GetValue<string>();
if (!isActive && statusText is not null)
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
currentTask = status["currentTask"]?.GetValue<string>()
?? status["task"]?.GetValue<string>()
?? (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;
}
/// <summary>
/// Loads agent IDs from the OpenClaw config file (openclaw.json).
/// Falls back to the known list if the config file is unavailable.
/// </summary>
private List<string> LoadAgentIdsFromConfig()
{
try
{
var configPath = configuration.GetValue<string>("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<string>();
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<string> GetDefaultAgentIds()
=> new() { "iris", "programmer", "reviewer", "architekt", "executor", "researcher" };
/// <summary>
/// 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).
/// </summary>
private async Task<(string Name, string Description, string[] Tags)> ReadAgentMetadataAsync(string agentId)
{
var name = string.Empty;
var description = string.Empty;
var tags = Array.Empty<string>();
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);
}
/// <summary>
/// Parses an IDENTITY.md file to extract Name and a short description (role/creature).
/// Looks for markdown list items like "- **Name:** ...", "- **Rolle:** ...", etc.
/// </summary>
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;
}
}
}
}
}
/// <summary>
/// Extracts a short description from SOUL.md (first heading or first paragraph after the "Rolle" section).
/// Returns the first ~200 characters of meaningful content.
/// </summary>
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<string>();
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<List<MessageEntry>> GetSessionHistoryAsync(
string sessionKey, int limit = 50, int offset = 0)
{
var result = new List<MessageEntry>();
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<string>();
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<ChatResponse> 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<string>()
?? result["status"]?.GetValue<string>()) == "ok";
var reply = details?["reply"]?.GetValue<string>()
?? result["reply"]?.GetValue<string>()
?? result["response"]?.GetValue<string>()
?? result["content"]?[0]?["text"]?.GetValue<string>();
var error = details?["error"]?.GetValue<string>()
?? result["error"]?.GetValue<string>();
return new ChatResponse(ok, reply, error);
}
catch (Exception ex)
{
return new ChatResponse(false, null, "Fehler: " + ex.Message);
}
}
public async Task<List<QueueItem>> GetQueueAsync()
{
try
{
var result = await InvokeToolAsync("cron", new { action = "list" });
if (result is null)
return new List<QueueItem>();
var details = result["details"];
var jobs = details?["jobs"] as JsonArray ?? result.AsArray();
if (jobs is null)
return new List<QueueItem>();
var items = new List<QueueItem>();
foreach (var j in jobs)
{
if (j is null) continue;
var id = j["id"]?.GetValue<string>() ?? "";
var name = j["name"]?.GetValue<string>() ?? id;
var status = j["state"]?["lastStatus"]?.GetValue<string>()
?? j["status"]?.GetValue<string>()
?? "unknown";
items.Add(new QueueItem(id, name, status));
}
return items;
}
catch
{
return new List<QueueItem>();
}
}
public async Task<DashboardStatus> 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<string>()
?? r["sessionKey"]?.GetValue<string>()
?? "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<AgentModelInfo?> 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<string>();
var provider = result["provider"]?.GetValue<string>();
if (model is null)
return null;
return new AgentModelInfo(model, provider ?? "unknown");
}
catch
{
return null;
}
}
public async Task<bool> 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"
};
}