1075 lines
40 KiB
C#
1075 lines
40 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>();
|
|
|
|
// Read queue once for workload calculation
|
|
var queueItems = new List<QueueItem>();
|
|
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<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"
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30)
|
|
{
|
|
var allEntries = new List<FeedEntry>();
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines a FeedEntry event type from message content heuristics.
|
|
/// </summary>
|
|
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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts a human-readable agent name and action summary from message content.
|
|
/// </summary>
|
|
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<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";
|
|
|
|
// Calculate waitTime from nextRun if available
|
|
var waitTime = "--";
|
|
var nextRunStr = j["nextRun"]?.GetValue<string>()
|
|
?? j["next_run"]?.GetValue<string>()
|
|
?? j["scheduledAt"]?.GetValue<string>();
|
|
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<QueueItem>();
|
|
}
|
|
}
|
|
|
|
public async Task<bool> DeleteCronJobAsync(string id)
|
|
{
|
|
try
|
|
{
|
|
var result = await InvokeToolAsync("cron", new { action = "delete", id });
|
|
return result is not null;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the agent's goal from workspace files (goals.md preferred, then AGENTS.md, SOUL.md).
|
|
/// Returns the first meaningful line or null.
|
|
/// </summary>
|
|
private async Task<string?> 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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string>() is not null
|
|
|| status?["task"]?.GetValue<string>() is not null;
|
|
if (hasTask)
|
|
progress += 20;
|
|
|
|
// Check for last activity timestamp — more recent = higher progress
|
|
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
|
?? status?["lastMessage"]?.GetValue<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates agent workload (0-100) based on queue items and active status.
|
|
/// More queued tasks or active sessions = higher workload.
|
|
/// </summary>
|
|
private static int CalculateAgentWorkload(string agentId, List<QueueItem> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5)
|
|
{
|
|
var entries = new List<AgentActivityEntry>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the list of available models by reading from the OpenClaw config,
|
|
/// with fallback to hardcoded list.
|
|
/// </summary>
|
|
public List<ModelOption> GetAvailableModels()
|
|
{
|
|
try
|
|
{
|
|
var configPath = configuration.GetValue<string>("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<ModelOption>();
|
|
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<ModelOption> 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"
|
|
};
|
|
}
|