Files
nexus/backend/Services/AgentService.cs
T
devops 95495a8332
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 3s
feat: complete task board workflow gates
2026-06-24 01:23:49 +02:00

301 lines
10 KiB
C#

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 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<string>? SubAgents,
string? IdentityName
);
public interface IAgentService
{
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
Task<IReadOnlySet<string>> 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<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)
{
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<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;
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<IReadOnlySet<string>> 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<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{
var path = configuration.GetValue<string>("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<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; }
}
}