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? 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 { public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) return null; if (reader.TokenType == JsonTokenType.String) { var primary = reader.GetString(); return string.IsNullOrWhiteSpace(primary) ? null : new AgentModelConfig { Primary = primary }; } 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, string Role, string Model, OperationalStatus Status, DateTimeOffset? LastSeen, string? Workspace, string? Description ); public sealed record AgentDetail( string Id, string Name, string Role, string Model, OperationalStatus Status, DateTimeOffset? LastSeen, string? Workspace, string? AgentDir, string? Description, IReadOnlyList? SubAgents, string? IdentityName ); public interface IAgentService { Task> GetAgentsAsync(CancellationToken cancellationToken); Task GetAgentAsync(string id, CancellationToken cancellationToken); Task> GetAllowedAgentIdsAsync(CancellationToken cancellationToken); } public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public async Task> 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(configs.Count); foreach (var config in configs) { var model = ResolveModel(config); var role = DeriveRole(config.Id); var description = config.Identity?.Theme ?? string.Empty; if (string.IsNullOrEmpty(description)) { description = config.Id switch { "main" => "Primary conversational agent — routing and general-purpose chat", _ => description }; } 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 )); } return agents.AsReadOnly(); } public async Task 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; var runtimeStatus = await runtime.GetStatusAsync(cancellationToken); var now = DateTimeOffset.UtcNow; var role = DeriveRole(config.Id); var description = config.Identity?.Theme ?? string.Empty; if (string.IsNullOrEmpty(description) && config.Id == "main") 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, Description: description, SubAgents: config.Subagents?.AllowAgents, IdentityName: config.Identity?.Name ); } public async Task> GetAllowedAgentIdsAsync(CancellationToken cancellationToken) { var configs = await LoadAgentConfigsAsync(cancellationToken); return configs .Where(config => !string.IsNullOrWhiteSpace(config.Id)) .Select(config => config.Id.Trim().ToLowerInvariant()) .DefaultIfEmpty() .Where(id => !string.IsNullOrWhiteSpace(id)) .ToHashSet(StringComparer.OrdinalIgnoreCase); } private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch { "iris" => "Orchestrator", "product-owner" => "Product Owner", "programmer" => "Developer", "programmer-fast" => "Developer", "reviewer" => "Reviewer", "architekt" => "Architect", "main" => "Assistant", _ => "Custom" }; private static string ResolveModel(AgentConfig config) => config.Model?.Primary ?? "deepseek/deepseek-v4-flash"; private async Task> LoadAgentConfigsAsync(CancellationToken cancellationToken) { var path = configuration.GetValue("AgentConfigPath") ?? "/home/node/.openclaw/openclaw.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(defaultsElement.GetRawText(), JsonOptions) : null; var configs = new List(); foreach (var agentElement in listElement.EnumerateArray()) { var config = JsonSerializer.Deserialize(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 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; } } }