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)
{