feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user