feat: ship agent-first mission control v0.2.57
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s

This commit is contained in:
AzuTear
2026-07-31 22:39:47 +02:00
parent 3bc7622977
commit f5552218bc
535 changed files with 95242 additions and 8791 deletions
+84 -224
View File
@@ -1,104 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
namespace Nexus.Api.Services;
public sealed record AgentConfig
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("workspace")]
public string? Workspace { get; init; }
[JsonPropertyName("agentDir")]
public string? AgentDir { get; init; }
[JsonPropertyName("model")]
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
[JsonPropertyName("identity")]
public AgentIdentityConfig? Identity { get; init; }
[JsonPropertyName("subagents")]
public SubAgentConfig? Subagents { get; init; }
}
public sealed record SubAgentConfig
{
[JsonPropertyName("allowAgents")]
public IReadOnlyList<string>? AllowAgents { get; init; }
}
public sealed record AgentIdentityConfig
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("theme")]
public string Theme { get; init; } = string.Empty;
}
public sealed record AgentModelConfig
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
}
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
{
public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
{
var primaryModel = reader.GetString();
return string.IsNullOrWhiteSpace(primaryModel) ? null : new AgentModelConfig { Primary = primaryModel };
}
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Agent model must be either a string or an object.");
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
string? primary = null;
foreach (var property in root.EnumerateObject())
{
if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase))
continue;
primary = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Null => null,
_ => throw new JsonException("Agent model primary must be a string.")
};
break;
}
return new AgentModelConfig { Primary = primary };
}
public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options)
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(value.Primary))
writer.WriteString("primary", value.Primary);
else
writer.WriteNull("primary");
writer.WriteEndObject();
}
}
public sealed record AgentInfo(
string Id,
string Name,
@@ -131,92 +34,116 @@ public interface IAgentService
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
}
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
/// <summary>
/// Projects OpenClaw's live agent inventory into Nexus' application contract.
/// OpenClaw remains the sole source of truth: no host config or workspace path
/// is consulted by this service.
/// </summary>
public sealed class AgentService(IOpenClawControlService openClaw) : IAgentService
{
private static readonly JsonSerializerOptions JsonOptions = new()
public async Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(
CancellationToken cancellationToken)
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var liveAgents = await openClaw.GetAgentsAsync(cancellationToken);
var sessions = await openClaw.GetSessionsAsync(500, cancellationToken);
var connection = openClaw.GetConnection();
var agents = new List<AgentInfo>(liveAgents.Items.Count);
public async Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
var runtimeStatus = await runtime.GetStatusAsync(cancellationToken);
var overallOperational = runtimeStatus.Status;
var now = DateTimeOffset.UtcNow;
var agents = new List<AgentInfo>(configs.Count);
foreach (var config in configs)
foreach (var live in liveAgents.Items
.Where(item => !string.IsNullOrWhiteSpace(item.Id))
.DistinctBy(item => item.Id, StringComparer.OrdinalIgnoreCase))
{
var model = ResolveModel(config);
var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty;
if (string.IsNullOrEmpty(description))
var session = FindLatestSession(sessions.Items, live.Id);
var description = live.Description;
if (string.IsNullOrWhiteSpace(description) &&
string.Equals(live.Id, "main", StringComparison.OrdinalIgnoreCase))
{
description = config.Id switch
{
"main" => "Primary conversational agent — routing and general-purpose chat",
_ => description
};
description = "Primary conversational agent — routing and general-purpose chat";
}
agents.Add(new AgentInfo(
Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role,
Model: model,
Status: overallOperational,
LastSeen: now,
Workspace: config.Workspace,
Description: description
));
Id: live.Id,
Name: string.IsNullOrWhiteSpace(live.Name) ? live.Id : live.Name,
Role: DeriveRole(live.Id),
Model: session?.Model ?? live.Model ?? "openclaw/default",
Status: ResolveStatus(connection.Connected, live.Status),
LastSeen: session?.UpdatedAt ?? connection.LastEventAt,
Workspace: live.Workspace,
Description: description));
}
return agents.AsReadOnly();
}
public async Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
public async Task<AgentDetail?> GetAgentAsync(
string id,
CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
var config = configs.FirstOrDefault(a =>
a.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
if (config is null) return null;
if (string.IsNullOrWhiteSpace(id))
return null;
var runtimeStatus = await runtime.GetStatusAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty;
var liveAgents = await openClaw.GetAgentsAsync(cancellationToken);
var live = liveAgents.Items.FirstOrDefault(item =>
string.Equals(item.Id, id, StringComparison.OrdinalIgnoreCase));
if (live is null)
return null;
if (string.IsNullOrEmpty(description) && config.Id == "main")
var sessions = await openClaw.GetSessionsAsync(500, cancellationToken);
var session = FindLatestSession(sessions.Items, live.Id);
var connection = openClaw.GetConnection();
var description = live.Description;
if (string.IsNullOrWhiteSpace(description) &&
string.Equals(live.Id, "main", StringComparison.OrdinalIgnoreCase))
{
description = "Primary conversational agent — routing and general-purpose chat";
}
return new AgentDetail(
Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role,
Model: ResolveModel(config),
Status: runtimeStatus.Status,
LastSeen: now,
Workspace: config.Workspace,
AgentDir: config.AgentDir,
Id: live.Id,
Name: string.IsNullOrWhiteSpace(live.Name) ? live.Id : live.Name,
Role: DeriveRole(live.Id),
Model: session?.Model ?? live.Model ?? "openclaw/default",
Status: ResolveStatus(connection.Connected, live.Status),
LastSeen: session?.UpdatedAt ?? connection.LastEventAt,
Workspace: live.Workspace,
AgentDir: null,
Description: description,
SubAgents: config.Subagents?.AllowAgents,
IdentityName: config.Identity?.Name
);
SubAgents: null,
IdentityName: string.IsNullOrWhiteSpace(live.Name) ? live.Id : live.Name);
}
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(
CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
return configs
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
.Select(config => config.Id.Trim().ToLowerInvariant())
var liveAgents = await openClaw.GetAgentsAsync(cancellationToken);
return liveAgents.Items
.Where(agent => !string.IsNullOrWhiteSpace(agent.Id))
.Select(agent => agent.Id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static Nexus.Api.Models.OpenClawSessionDto? FindLatestSession(
IReadOnlyList<Nexus.Api.Models.OpenClawSessionDto> sessions,
string agentId)
=> sessions
.Where(item => string.Equals(item.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.UpdatedAt)
.FirstOrDefault();
private static OperationalStatus ResolveStatus(bool connected, string? liveStatus)
{
if (!connected)
return OperationalStatus.Offline;
return liveStatus?.Trim().ToLowerInvariant() switch
{
"degraded" or "stale" or "warning" => OperationalStatus.Degraded,
"offline" or "failed" or "error" => OperationalStatus.Offline,
"unknown" or "unsupported" => OperationalStatus.Unknown,
_ => OperationalStatus.Online
};
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
@@ -228,71 +155,4 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
"main" => "Assistant",
_ => "Custom"
};
private static string ResolveModel(AgentConfig config)
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{
var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/agents-sanitized.json";
if (!File.Exists(path))
return BuildFallbackConfigs();
var json = await File.ReadAllTextAsync(path, cancellationToken);
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
var root = document.RootElement;
if (!root.TryGetProperty("agents", out var agentsElement))
return BuildFallbackConfigs();
if (!agentsElement.TryGetProperty("list", out var listElement))
return BuildFallbackConfigs();
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
: null;
var configs = new List<AgentConfig>();
foreach (var agentElement in listElement.EnumerateArray())
{
var config = JsonSerializer.Deserialize<AgentConfig>(agentElement.GetRawText(), JsonOptions);
if (config is null || string.IsNullOrWhiteSpace(config.Id))
continue;
// Inherit defaults for missing fields
if (string.IsNullOrWhiteSpace(config.Name))
config = config with { Name = config.Id };
if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
config = config with { Workspace = defaults.Workspace };
configs.Add(config);
}
return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
}
private static IReadOnlyList<AgentConfig> BuildFallbackConfigs()
=> AgentIdentityCatalog.DefaultConfiguredAgentIds
.Select(id => new AgentConfig
{
Id = id,
Name = id,
Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" }
})
.ToList()
.AsReadOnly();
private sealed record AgentDefaults
{
[JsonPropertyName("workspace")]
public string? Workspace { get; init; }
[JsonPropertyName("model")]
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
}
}