Files
nexus/backend/Controllers/AgentsController.cs
T
AzuTear cd8c78d165
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
feat(stability): unify readiness and recovery
2026-08-01 01:21:33 +02:00

329 lines
13 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Http;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/agents")]
public class AgentsController(
IAgentService agentService,
IOpenClawChatService chat,
IActivityRepository activityRepo,
IOpenClawAgentConfigurationService agentConfiguration,
IDashboardService dashboardService,
ILogger<AgentsController> logger) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<AgentListResponse>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgents(CancellationToken ct)
{
var agents = await agentService.GetAgentsAsync(ct);
return Results.Ok(agents.Select(a => new AgentListResponse(
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(AgentDetailResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IResult> GetAgent(string id, CancellationToken ct)
{
var agent = await agentService.GetAgentAsync(id, ct);
if (agent is null) return Results.NotFound();
return Results.Ok(new AgentDetailResponse(
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
agent.SubAgents, agent.IdentityName));
}
[HttpGet("{id}/activity")]
[ProducesResponseType(typeof(IReadOnlyList<AgentActivityResponse>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
{
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
var activity = items
.Select(x => new AgentActivityResponse(x.Id, x.Type, x.Message, x.CreatedAt, "activity"))
.ToList();
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 10);
foreach (var entry in gatewayEntries)
activity.Add(new AgentActivityResponse(null, "thinking", entry.Text, entry.Timestamp, entry.Source, entry.Time));
return Results.Ok(activity
.OrderByDescending(x => x.At)
.Take(50));
}
[HttpGet("{id}/summary")]
[ProducesResponseType(typeof(AgentSummaryResponse), StatusCodes.Status200OK)]
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
{
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 8);
return Results.Ok(AgentSummaryBuilder.Build(recent, gatewayEntries, DateTimeOffset.UtcNow));
}
[HttpPost("{id}/command")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(AgentCommandResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> SendCommand(string id, [FromBody] AgentCommandRequest request, CancellationToken ct)
{
var message = request.Message?.Trim();
if (string.IsNullOrWhiteSpace(message) || message.Length > 8000)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["message"] = ["Message must contain between 1 and 8000 characters."] });
var conversationId = $"nexus-command-{id}-{Guid.NewGuid():N}";
try
{
var context = OpenClawInvocationContextFactory.Create(
User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.Identity?.Name,
Request.Headers["Idempotency-Key"].FirstOrDefault(),
Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? HttpContext.TraceIdentifier,
Request.Headers["traceparent"].FirstOrDefault()
?? Activity.Current?.Id);
var result = await chat.SendAsync(
message,
conversationId,
id,
new Models.OpenClawInvocationMetadata(
context.IdempotencyKey,
context.CorrelationId,
context.Actor,
context.TraceParent),
ct);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "agent",
Message = $"Command dispatched to agent {id} as durable run {result.RunId}"
}, ct);
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
}
catch (Exception exception)
{
logger.LogWarning(exception, "Agent command failed for {AgentId}", id);
return Results.Problem(
title: "Agent command failed",
detail: $"Could not send command to agent {id}: {exception.Message}",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
// ── Config Editor ──
[HttpGet("{id}/config")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetConfig(string id, CancellationToken ct)
{
var files = await agentConfiguration.GetAgentFilesAsync(id, ct);
return Results.Ok(files.Files.Select(file => new
{
FileName = file.Name,
file.Size,
ModifiedAt = file.UpdatedAt,
file.Missing,
file.ContentHash
}));
}
[HttpGet("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
{
var file = await agentConfiguration.GetAgentFileAsync(id, fileName, ct);
return file.Missing
? Results.NotFound()
: Results.Ok(new
{
FileName = file.Name,
file.Content,
file.Size,
ModifiedAt = file.UpdatedAt,
file.ContentHash
});
}
[HttpPut("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{
if (request.Content is null)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = ["Content is required."]
});
if (string.IsNullOrWhiteSpace(request.ExpectedHash))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["expectedHash"] = ["ExpectedHash is required."]
});
try
{
var caller = DescribeCaller(HttpContext.User);
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Idempotency-Key"] = ["Idempotency-Key header is required."]
});
}
var invocation = OpenClawInvocationContext.Create(
caller,
idempotencyKey,
Request.Headers["X-Correlation-ID"].FirstOrDefault(),
Request.Headers["traceparent"].FirstOrDefault());
var result = await agentConfiguration.SetAgentFileAsync(
id,
fileName,
new Nexus.Api.Models.UpdateOpenClawAgentFileRequest(request.Content, request.ExpectedHash),
invocation,
ct);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save agent={id} file={fileName} caller={caller} verified={result.Verified} state={result.State}",
}, ct);
return Results.Ok(new
{
FileName = result.File.Name,
result.File.Size,
ModifiedAt = result.File.UpdatedAt,
result.File.ContentHash,
result.Verified,
result.State,
result.Message
});
}
catch (OpenClawAgentConfigurationConflictException ex)
{
return NexusHttpResults.Problem(
StatusCodes.Status409Conflict,
ex.Message,
NexusProblemCodes.Conflict,
extensions: new Dictionary<string, object?>
{
["expectedHash"] = ex.ExpectedHash,
["currentHash"] = ex.CurrentHash
});
}
catch (OpenClawAgentConfigurationValidationException ex)
{
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={DescribeCaller(HttpContext.User)} validation=failed code=validation_failed",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[ex.Field] = [ex.Message]
});
}
}
private static string DescribeCaller(ClaimsPrincipal user)
{
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
}
}
public sealed record AgentActivityResponse(
long? Id,
string Type,
string Message,
DateTimeOffset At,
string Source,
string? RelativeTime = null
);
public sealed record AgentSummaryResponse(
AgentSummaryItemResponse Now,
AgentSummaryItemResponse Today,
DateTimeOffset GeneratedAt
);
public sealed record AgentSummaryItemResponse(
string Text,
string Source,
DateTimeOffset? Timestamp
);
public static class AgentSummaryBuilder
{
public static AgentSummaryResponse Build(
IReadOnlyList<Nexus.Api.Data.ActivityEvent> activity,
IReadOnlyList<Nexus.Api.Models.AgentActivityEntry> gatewayEntries,
DateTimeOffset nowUtc)
{
var points = activity
.Select(entry => new SummaryPoint(entry.Message, entry.CreatedAt, "nexus-activity"))
.Concat(gatewayEntries.Select(entry => new SummaryPoint(entry.Text, entry.Timestamp, entry.Source)))
.Select(point => point with { Text = AgentActivityText.RedactForDisplay(point.Text) })
.Where(point => !string.IsNullOrWhiteSpace(point.Text))
.OrderByDescending(point => point.Timestamp)
.ToList();
var current = points.FirstOrDefault();
var now = current is null
? new AgentSummaryItemResponse("Keine aktuelle Aktivitaet.", "none", null)
: new AgentSummaryItemResponse(current.Text, current.Source, current.Timestamp);
var windowStart = nowUtc.AddHours(-24);
var todayPoints = points
.Where(point => point.Timestamp >= windowStart)
.ToList();
AgentSummaryItemResponse today;
if (todayPoints.Count == 0)
{
today = new AgentSummaryItemResponse("Heute keine verwertbaren Checkpoints.", "none", null);
}
else
{
var snippets = todayPoints
.Select(point => point.Text)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.ToList();
var extraCount = Math.Max(0, todayPoints.Count - snippets.Count);
var text = $"Letzte 24h: {string.Join(" | ", snippets)}";
if (extraCount > 0)
text += $" (+{extraCount} weitere)";
var source = todayPoints.Select(point => point.Source).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1
? todayPoints[0].Source
: "derived-mixed";
today = new AgentSummaryItemResponse(text, source, todayPoints[0].Timestamp);
}
return new AgentSummaryResponse(now, today, nowUtc);
}
private sealed record SummaryPoint(string Text, DateTimeOffset Timestamp, string Source);
}