feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Api.Models;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Budget;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Memory;
|
||||
@@ -14,13 +15,16 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Engine;
|
||||
|
||||
public sealed class AgentEngine : IAgentMessageRouter
|
||||
public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecutor
|
||||
{
|
||||
private readonly IChatCompletionClient _client;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly PermissionGate _permissionGate;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly IMemoryRepository? _memoryRepository;
|
||||
private readonly Tasks.ITaskRepository? _taskRepository;
|
||||
private readonly IAuditRepository? _auditRepository;
|
||||
private readonly Staging.StagingGate? _stagingGate;
|
||||
private readonly IUsageRepository? _usageRepository;
|
||||
private readonly BudgetGuard? _budgetGuard;
|
||||
private readonly ModelPricingCatalog? _pricing;
|
||||
@@ -67,7 +71,10 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
ILoggerFactory loggerFactory,
|
||||
IMemoryRepository? memoryRepository = null,
|
||||
IUsageRepository? usageRepository = null,
|
||||
ModelPricingCatalog? pricing = null)
|
||||
ModelPricingCatalog? pricing = null,
|
||||
Tasks.ITaskRepository? taskRepository = null,
|
||||
IAuditRepository? auditRepository = null,
|
||||
Staging.StagingGate? stagingGate = null)
|
||||
{
|
||||
_client = client;
|
||||
_toolRegistry = toolRegistry;
|
||||
@@ -75,6 +82,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
_stateStore = stateStore;
|
||||
_loggerFactory = loggerFactory;
|
||||
_memoryRepository = memoryRepository;
|
||||
_taskRepository = taskRepository;
|
||||
_auditRepository = auditRepository;
|
||||
_stagingGate = stagingGate;
|
||||
_usageRepository = usageRepository;
|
||||
_pricing = pricing;
|
||||
_budgetGuard = usageRepository is null ? null : new BudgetGuard(usageRepository);
|
||||
@@ -100,14 +110,18 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
AgentConfig agentConfig,
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt)
|
||||
CancellationToken externalCt,
|
||||
string? source = null,
|
||||
string? taskId = null)
|
||||
{
|
||||
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
|
||||
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
|
||||
return denied;
|
||||
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt);
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source);
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -115,7 +129,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
AgentConfig agentConfig,
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt)
|
||||
CancellationToken externalCt,
|
||||
string runId,
|
||||
string? source)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -157,7 +173,11 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
{
|
||||
Model = agentConfig.Model,
|
||||
Messages = messages,
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
|
||||
// B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
|
||||
MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
|
||||
? agentConfig.LoopGuard.MaxResponseTokens
|
||||
: null
|
||||
};
|
||||
|
||||
var response = await _client.CompleteAsync(request, ct);
|
||||
@@ -194,7 +214,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
var toolResult = await ExecuteToolCallAsync(
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct);
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
|
||||
|
||||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||||
}
|
||||
@@ -282,11 +302,14 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt,
|
||||
string? source = null)
|
||||
string? source = null,
|
||||
string? taskId = null)
|
||||
{
|
||||
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
|
||||
return denied;
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
|
||||
// wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
|
||||
using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
|
||||
@@ -307,8 +330,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId);
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
@@ -323,7 +347,8 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken runCt,
|
||||
string? source)
|
||||
string? source,
|
||||
string runId)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -380,7 +405,11 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
{
|
||||
Model = agentConfig.Model,
|
||||
Messages = messages,
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
|
||||
// B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
|
||||
MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
|
||||
? agentConfig.LoopGuard.MaxResponseTokens
|
||||
: null
|
||||
};
|
||||
|
||||
var response = await _client.CompleteAsync(request, ct);
|
||||
@@ -418,7 +447,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
var toolResult = await ExecuteToolCallAsync(
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct);
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
|
||||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||||
}
|
||||
|
||||
@@ -538,6 +567,12 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
return _runningChats.ContainsKey(agentId);
|
||||
}
|
||||
|
||||
/// <summary>Anzahl Agenten mit mindestens einem aktiven Chat-Lauf — für Diagnose/Heartbeat.</summary>
|
||||
public int RunningChatCount
|
||||
{
|
||||
get { lock (_lock) return _runningChats.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten,
|
||||
/// die beim nächsten Schritt tatsächlich an das Modell gehen.
|
||||
@@ -805,9 +840,19 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string instanceId,
|
||||
IReadOnlyList<IAgentTool> availableTools,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
CancellationToken ct,
|
||||
string runId,
|
||||
string? source)
|
||||
{
|
||||
var toolName = toolCall.Function.Name;
|
||||
var arguments = toolCall.Function.Arguments ?? "";
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Das Audit wird von der Engine gestempelt (A3) — Herkunft aus dem Wissen der
|
||||
// Engine, nie aus dem Tool-Ergebnis. Best effort: ein Audit-Fehler darf den Lauf
|
||||
// nicht scheitern lassen.
|
||||
Task Audit(AuditStatus status, string summary)
|
||||
=> RecordAuditAsync(runId, agentConfig, source, toolName, arguments, status, summary, sw.ElapsedMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -815,29 +860,36 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
|
||||
var tool = availableTools.FirstOrDefault(t => t.Name == toolName);
|
||||
if (tool is null)
|
||||
{
|
||||
await Audit(AuditStatus.NotFound, $"Tool '{toolName}' nicht zugewiesen/unbekannt");
|
||||
return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found."));
|
||||
}
|
||||
|
||||
// Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt
|
||||
// ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
|
||||
if (_stagingGate is not null)
|
||||
{
|
||||
var intercept = await _stagingGate.InterceptAsync(
|
||||
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct);
|
||||
|
||||
if (intercept.Outcome == Staging.StagingOutcome.Denied)
|
||||
{
|
||||
await Audit(AuditStatus.Denied, intercept.Message);
|
||||
return JsonSerializer.Serialize(new { error = intercept.Message });
|
||||
}
|
||||
|
||||
if (intercept.Outcome == Staging.StagingOutcome.Staged)
|
||||
{
|
||||
await Audit(AuditStatus.Staged, intercept.Message);
|
||||
return intercept.Message; // dem Agenten als reguläres Tool-Ergebnis
|
||||
}
|
||||
}
|
||||
|
||||
var input = string.IsNullOrWhiteSpace(toolCall.Function.Arguments)
|
||||
? default
|
||||
: JsonDocument.Parse(toolCall.Function.Arguments).RootElement;
|
||||
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
|
||||
? cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
|
||||
|
||||
var context = new AgentToolContext(
|
||||
agentConfig.AgentId,
|
||||
instanceId,
|
||||
toolConfig,
|
||||
_stateStore,
|
||||
toolLogger,
|
||||
ct,
|
||||
agentConfig.WorkspacePath,
|
||||
agentConfig.SharedWorkspacePath,
|
||||
this,
|
||||
_memoryRepository);
|
||||
var context = BuildToolContext(agentConfig, instanceId, toolName, ct);
|
||||
|
||||
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
|
||||
|
||||
@@ -846,7 +898,10 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await Audit(AuditStatus.Error, result.ErrorMessage ?? "");
|
||||
return JsonSerializer.Serialize(new { error = result.ErrorMessage });
|
||||
}
|
||||
|
||||
var content = TruncateToolResult(result.Content, agentConfig.MaxToolResultChars);
|
||||
if (content.Length != result.Content.Length)
|
||||
@@ -856,22 +911,92 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
toolName, result.Content.Length, agentConfig.MaxToolResultChars);
|
||||
}
|
||||
|
||||
await Audit(AuditStatus.Ok, "");
|
||||
return content;
|
||||
}
|
||||
catch (ToolAccessDeniedException ex)
|
||||
{
|
||||
logger.LogWarning("Tool access denied: {Message}", ex.Message);
|
||||
await Audit(AuditStatus.Denied, ex.Message);
|
||||
return JsonSerializer.Serialize(new { error = ex.Message });
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
|
||||
// Schritt weiter und der Abbruch greift erst verzögert.
|
||||
// Schritt weiter und der Abbruch greift erst verzögert. Auch kein Audit —
|
||||
// der Aufruf kam nicht zum Abschluss.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
|
||||
await Audit(AuditStatus.Error, ex.Message);
|
||||
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private AgentToolContext BuildToolContext(
|
||||
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct)
|
||||
{
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
|
||||
? cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
|
||||
|
||||
return new AgentToolContext(
|
||||
agentConfig.AgentId,
|
||||
instanceId,
|
||||
toolConfig,
|
||||
_stateStore,
|
||||
toolLogger,
|
||||
ct,
|
||||
agentConfig.WorkspacePath,
|
||||
agentConfig.SharedWorkspacePath,
|
||||
this,
|
||||
_memoryRepository,
|
||||
_taskRepository);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen freigegebenen, eingefrorenen Aufruf aus (A2) — mit gültigem Tool-Kontext,
|
||||
/// aber ohne LLM-Schleife und ohne erneute Staging-Prüfung. Genau der übergebene
|
||||
/// Argument-JSON wird ausgeführt (Plan-Freeze).
|
||||
/// </summary>
|
||||
public async Task<string> ExecuteApprovedCallAsync(
|
||||
string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
|
||||
{
|
||||
var config = _agentConfigProvider?.Invoke().FirstOrDefault(a => a.AgentId == agentId);
|
||||
if (config is null)
|
||||
return JsonSerializer.Serialize(new { error = $"Agent '{agentId}' nicht gefunden." });
|
||||
|
||||
var agentTool = _toolRegistry.GetForAgent(config).FirstOrDefault(t => t.Name == tool)
|
||||
?? _toolRegistry.Get(tool);
|
||||
if (agentTool is null)
|
||||
return JsonSerializer.Serialize(new { error = $"Tool '{tool}' nicht gefunden." });
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var input = string.IsNullOrWhiteSpace(argumentsJson)
|
||||
? default
|
||||
: JsonDocument.Parse(argumentsJson).RootElement;
|
||||
|
||||
var context = BuildToolContext(config, _instanceId, tool, ct);
|
||||
var result = await agentTool.ExecuteAsync(input, context, ct);
|
||||
|
||||
var status = result.Success ? AuditStatus.Ok : AuditStatus.Error;
|
||||
await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
|
||||
status, result.Success ? "Freigegeben ausgeführt" : (result.ErrorMessage ?? ""), sw.ElapsedMilliseconds);
|
||||
|
||||
return result.Success
|
||||
? TruncateToolResult(result.Content, config.MaxToolResultChars)
|
||||
: JsonSerializer.Serialize(new { error = result.ErrorMessage });
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
|
||||
AuditStatus.Error, ex.Message, sw.ElapsedMilliseconds);
|
||||
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
@@ -938,6 +1063,84 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Audit-Log und Receipts (A3) ───
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Tool-Aufruf ins Audit-Log. Best effort — ein Fehler hierbei darf den
|
||||
/// Lauf nicht scheitern lassen; die eigentliche Arbeit ist bereits getan.
|
||||
/// </summary>
|
||||
private async Task RecordAuditAsync(
|
||||
string runId, AgentConfig agentConfig, string? source, string tool,
|
||||
string arguments, AuditStatus status, string summary, long durationMs)
|
||||
{
|
||||
if (_auditRepository is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _auditRepository.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentConfig.AgentId,
|
||||
Model = agentConfig.Model,
|
||||
Source = AuditSource.Normalize(source),
|
||||
Tool = tool,
|
||||
Arguments = Cap(arguments, 4_000),
|
||||
Status = status,
|
||||
Summary = Cap(summary, 500),
|
||||
DurationMs = durationMs,
|
||||
OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Audit")
|
||||
.LogWarning(ex, "Audit-Eintrag konnte nicht geschrieben werden ({Tool})", tool);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit.
|
||||
/// </summary>
|
||||
private async Task RecordReceiptAsync(
|
||||
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId)
|
||||
{
|
||||
if (_auditRepository is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
|
||||
|
||||
await _auditRepository.RecordReceiptAsync(new RunReceipt
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentConfig.AgentId,
|
||||
Model = agentConfig.Model,
|
||||
Source = AuditSource.Normalize(source),
|
||||
TaskId = taskId,
|
||||
Status = result.Status.ToString(),
|
||||
StepCount = result.StepCount,
|
||||
PromptTokens = result.PromptTokens,
|
||||
CompletionTokens = result.CompletionTokens,
|
||||
CachedTokens = result.CachedTokens,
|
||||
CostUsd = estimate?.Usd ?? 0m,
|
||||
CostIsKnown = estimate?.IsKnown ?? false,
|
||||
DurationMs = (long)result.Duration.TotalMilliseconds,
|
||||
ResultRef = Cap(result.FinalMessage ?? "", 500),
|
||||
OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Receipt")
|
||||
.LogWarning(ex, "Receipt konnte nicht geschrieben werden für {AgentId}", agentConfig.AgentId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Cap(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max] + "…";
|
||||
|
||||
/// <summary>Sammelt die Token-Zahlen über alle Schritte eines Runs.</summary>
|
||||
private sealed class TokenTally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user