Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2877035c5c | |||
| 6a1366b472 |
@@ -61,84 +61,329 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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()
|
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||||
{
|
{
|
||||||
var agentDefs = new[]
|
// Fallback hardcoded descriptions and tags known for each agent
|
||||||
|
var knownInfo = new Dictionary<string, (string Name, string Description, string[] Tags)>
|
||||||
{
|
{
|
||||||
new { Id = "iris", Name = "Chief of Staff", Model = "deepseek/deepseek-v4-flash",
|
["iris"] = (
|
||||||
Description = "Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
|
"Iris",
|
||||||
Tags = new[] { "Orchestration", "Delegation", "Approval", "Risk Management" } },
|
"Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
|
||||||
new { Id = "programmer", Name = "Full-Stack Developer", Model = "deepseek/deepseek-v4-flash",
|
new[] { "Orchestration", "Delegation", "Approval", "Risk Management" }
|
||||||
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" } },
|
["programmer"] = (
|
||||||
new { Id = "reviewer", Name = "Code Quality Assurance", Model = "deepseek/deepseek-v4-pro",
|
"Full-Stack Developer",
|
||||||
Description = "Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
|
"Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.",
|
||||||
Tags = new[] { "Code Review", "Testing", "Security", "Quality" } },
|
new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" }
|
||||||
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.",
|
["reviewer"] = (
|
||||||
Tags = new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" } },
|
"Code Quality Assurance",
|
||||||
new { Id = "executor", Name = "Host Executor", Model = "deepseek/deepseek-v4-flash",
|
"Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
|
||||||
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.",
|
new[] { "Code Review", "Testing", "Security", "Quality" }
|
||||||
Tags = new[] { "Docker", "Shell", "Host", "Deployment" } },
|
),
|
||||||
new { Id = "researcher", Name = "Research & Analysis", Model = "deepseek/deepseek-v4-pro",
|
["architekt"] = (
|
||||||
Description = "Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.",
|
"Infrastructure Architect",
|
||||||
Tags = new[] { "Research", "Quellenprüfung", "Analyse", "Docs" } },
|
"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>();
|
var agents = new List<DashboardAgentInfo>();
|
||||||
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<string>();
|
||||||
|
|
||||||
|
// 3. Extract activity from session_status
|
||||||
var isActive = false;
|
var isActive = false;
|
||||||
string? currentTask = null;
|
string? currentTask = null;
|
||||||
try
|
if (status is not null)
|
||||||
{
|
{
|
||||||
var memDir = "/mnt/workspace-" + def.Id + "/memory";
|
// Check explicit isActive field
|
||||||
if (Directory.Exists(memDir))
|
var activeVal = status["isActive"];
|
||||||
{
|
if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.True)
|
||||||
var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories)
|
isActive = true;
|
||||||
.Select(f => new FileInfo(f))
|
else if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.String)
|
||||||
.OrderByDescending(f => f.LastWriteTimeUtc)
|
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
.FirstOrDefault();
|
|
||||||
if (latestFile is not null)
|
// Fall back to status text
|
||||||
{
|
var statusText = status["status"]?.GetValue<string>();
|
||||||
var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc;
|
if (!isActive && statusText is not null)
|
||||||
isActive = age.TotalMinutes < 15;
|
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|
||||||
if (isActive)
|
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
|
||||||
{
|
|
||||||
try
|
currentTask = status["currentTask"]?.GetValue<string>()
|
||||||
{
|
?? status["task"]?.GetValue<string>()
|
||||||
var firstLine = File.ReadLines(latestFile.FullName).FirstOrDefault()?.Trim();
|
?? (isActive ? "Working..." : null);
|
||||||
if (!string.IsNullOrWhiteSpace(firstLine) && firstLine.Length > 60)
|
}
|
||||||
currentTask = firstLine[..60];
|
|
||||||
else if (!string.IsNullOrWhiteSpace(firstLine))
|
// 4. Try to read workspace metadata for richer info
|
||||||
currentTask = firstLine;
|
var (name, description, tags) = await ReadAgentMetadataAsync(id);
|
||||||
else
|
|
||||||
currentTask = "Working...";
|
// 5. Fallback to known info if workspace metadata not available
|
||||||
}
|
if (string.IsNullOrWhiteSpace(name) && knownInfo.TryGetValue(id, out var kn))
|
||||||
catch
|
{
|
||||||
{
|
name = kn.Name;
|
||||||
currentTask = "Working...";
|
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(
|
agents.Add(new DashboardAgentInfo(
|
||||||
Id: def.Id,
|
Id: id,
|
||||||
Name: def.Name,
|
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
|
||||||
Role: DeriveRole(def.Id),
|
Role: DeriveRole(id),
|
||||||
Model: def.Model,
|
Model: model,
|
||||||
IsActive: isActive,
|
IsActive: isActive,
|
||||||
CurrentTask: currentTask,
|
CurrentTask: currentTask,
|
||||||
Description: def.Description,
|
Description: description,
|
||||||
Tags: def.Tags
|
Tags: tags
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return agents;
|
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(
|
public async Task<List<MessageEntry>> GetSessionHistoryAsync(
|
||||||
string sessionKey, int limit = 50, int offset = 0)
|
string sessionKey, int limit = 50, int offset = 0)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user