1194 lines
46 KiB
C#
1194 lines
46 KiB
C#
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;
|
||
using ClawdDotNet.Core.Security;
|
||
using ClawdDotNet.Core.Accounting;
|
||
using ClawdDotNet.Core.Tools;
|
||
using ClawdDotNet.Core.State;
|
||
using ClawdDotNet.Core.Storage;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace ClawdDotNet.Core.Engine;
|
||
|
||
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;
|
||
private readonly ILoggerFactory _loggerFactory;
|
||
|
||
/// <summary>Tagesgrenzen der Instanz. Wird vom Host gesetzt.</summary>
|
||
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
|
||
private readonly ContextCompactor _compactor;
|
||
|
||
private readonly Dictionary<string, List<ChatEntry>> _chatHistories = new();
|
||
private readonly Dictionary<string, List<ChatMessage>> _chatContexts = new();
|
||
private readonly Lock _lock = new();
|
||
|
||
/// <summary>
|
||
/// Alle aktiven Chat-Läufe je Agent — laufende wie wartende. Mehrere Quellen können
|
||
/// denselben Agenten gleichzeitig ansprechen (WebView, ToolJob, AgentComm), deshalb
|
||
/// eine Liste: AbortChat muss jeden davon erreichen.
|
||
/// </summary>
|
||
private readonly Dictionary<string, List<CancellationTokenSource>> _runningChats = new();
|
||
|
||
/// <summary>
|
||
/// Serialisiert ChatAsync pro Agent. Der Konversationskontext ist eine geteilte
|
||
/// Liste — liefen zwei Chats desselben Agenten gleichzeitig, verschränkten sich ihre
|
||
/// Nachrichten zu einer ungültigen Tool-Sequenz, die die API mit HTTP 400 ablehnt.
|
||
/// Verschiedene Agenten bleiben unabhängig voneinander.
|
||
/// </summary>
|
||
private readonly Dictionary<string, SemaphoreSlim> _agentGates = new();
|
||
|
||
private Func<IReadOnlyList<AgentConfig>>? _agentConfigProvider;
|
||
private Func<string, string?>? _agentDirResolver;
|
||
private string _instanceId = "";
|
||
|
||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||
{
|
||
WriteIndented = true,
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||
};
|
||
|
||
public AgentEngine(
|
||
IChatCompletionClient client,
|
||
ToolRegistry toolRegistry,
|
||
PermissionGate permissionGate,
|
||
IStateStore stateStore,
|
||
ILoggerFactory loggerFactory,
|
||
IMemoryRepository? memoryRepository = null,
|
||
IUsageRepository? usageRepository = null,
|
||
ModelPricingCatalog? pricing = null,
|
||
Tasks.ITaskRepository? taskRepository = null,
|
||
IAuditRepository? auditRepository = null,
|
||
Staging.StagingGate? stagingGate = null)
|
||
{
|
||
_client = client;
|
||
_toolRegistry = toolRegistry;
|
||
_permissionGate = permissionGate;
|
||
_stateStore = stateStore;
|
||
_loggerFactory = loggerFactory;
|
||
_memoryRepository = memoryRepository;
|
||
_taskRepository = taskRepository;
|
||
_auditRepository = auditRepository;
|
||
_stagingGate = stagingGate;
|
||
_usageRepository = usageRepository;
|
||
_pricing = pricing;
|
||
_budgetGuard = usageRepository is null ? null : new BudgetGuard(usageRepository);
|
||
_compactor = new ContextCompactor(client, loggerFactory);
|
||
}
|
||
|
||
public event Action<string, string>? OnStepCompleted;
|
||
|
||
/// <summary>
|
||
/// Wird ausgelöst wenn ein neuer ChatEntry hinzugefügt wird (agentId, role, content, source).
|
||
/// Erlaubt der UI, Nachrichten aus Hintergrund-Runs (ToolJobs, AgentComm) live anzuzeigen.
|
||
/// Source gibt an, woher die Nachricht kam (webview, telegram, agentcomm, job, null).
|
||
/// </summary>
|
||
public event Action<string, string, string, string?>? OnChatEntryAdded;
|
||
|
||
/// <summary>
|
||
/// Wird nach jedem abgeschlossenen Run/Chat ausgelöst (model, result).
|
||
/// Erlaubt der UI, Token-Verbrauch und Kosten für ALLE Runs zu tracken.
|
||
/// </summary>
|
||
public event Action<string, AgentRunResult>? OnRunCompleted;
|
||
|
||
public async Task<AgentRunResult> RunAsync(
|
||
AgentConfig agentConfig,
|
||
string userMessage,
|
||
string instanceId,
|
||
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 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;
|
||
}
|
||
|
||
private async Task<AgentRunResult> RunCoreAsync(
|
||
AgentConfig agentConfig,
|
||
string userMessage,
|
||
string instanceId,
|
||
CancellationToken externalCt,
|
||
string runId,
|
||
string? source)
|
||
{
|
||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
|
||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||
var sw = Stopwatch.StartNew();
|
||
|
||
using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
|
||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt, timeoutCts.Token);
|
||
var ct = linkedCts.Token;
|
||
|
||
logger.LogInformation("Agent run started: {AgentId}, model={Model}",
|
||
agentConfig.AgentId, agentConfig.Model);
|
||
|
||
try
|
||
{
|
||
var tools = _toolRegistry.GetForAgent(agentConfig);
|
||
var toolDefinitions = BuildToolDefinitions(tools);
|
||
|
||
var messages = new List<ChatMessage>();
|
||
|
||
var systemPrompt = agentConfig.FullSystemPrompt;
|
||
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
||
messages.Add(ChatMessage.System(systemPrompt));
|
||
|
||
messages.Add(ChatMessage.User(userMessage));
|
||
|
||
string? finalMessage = null;
|
||
var tally = new TokenTally();
|
||
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
||
|
||
while (true)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
loopGuard.RecordStep();
|
||
|
||
if (cachingEnabled)
|
||
PromptCache.ApplyBreakpoints(messages);
|
||
|
||
var request = new ChatRequest
|
||
{
|
||
Model = agentConfig.Model,
|
||
Messages = messages,
|
||
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);
|
||
|
||
var promptTokens = 0;
|
||
if (response.Usage is not null)
|
||
{
|
||
tally.Add(response.Usage);
|
||
promptTokens = response.Usage.PromptTokens;
|
||
loopGuard.RecordTokens(response.Usage.TotalTokens);
|
||
}
|
||
|
||
// Context-Compaction auch in RunAsync – verhindert Token-Explosion bei komplexen Analysen
|
||
var compacted = await _compactor.CompactIfNeededAsync(
|
||
messages, promptTokens, agentConfig.LoopGuard, agentConfig.Model, ct);
|
||
if (compacted)
|
||
{
|
||
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
|
||
}
|
||
|
||
var choice = response.Choices.FirstOrDefault();
|
||
if (choice is null)
|
||
{
|
||
finalMessage = "[No response from model]";
|
||
break;
|
||
}
|
||
|
||
var assistantMessage = choice.Message;
|
||
|
||
if (assistantMessage.ToolCalls is { Count: > 0 })
|
||
{
|
||
messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls));
|
||
|
||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||
{
|
||
var toolResult = await ExecuteToolCallAsync(
|
||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
|
||
|
||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||
}
|
||
|
||
OnStepCompleted?.Invoke(agentConfig.AgentId,
|
||
$"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed");
|
||
}
|
||
else
|
||
{
|
||
finalMessage = assistantMessage.Content ?? "";
|
||
break;
|
||
}
|
||
}
|
||
|
||
sw.Stop();
|
||
logger.LogInformation(
|
||
"Agent run completed: {AgentId}, steps={Steps}, tokens={Tokens} (davon {Cached} aus Cache), duration={Duration}ms",
|
||
agentConfig.AgentId, loopGuard.Steps, tally.Total, tally.Cached, sw.ElapsedMilliseconds);
|
||
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId,
|
||
AgentRunStatus.Completed,
|
||
finalMessage,
|
||
loopGuard.Steps,
|
||
tally.Total,
|
||
sw.Elapsed)
|
||
{
|
||
PromptTokens = tally.Prompt,
|
||
CompletionTokens = tally.Completion,
|
||
CachedTokens = tally.Cached
|
||
};
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
|
||
{
|
||
sw.Stop();
|
||
logger.LogWarning("Agent run timed out: {AgentId} after {Duration}ms",
|
||
agentConfig.AgentId, sw.ElapsedMilliseconds);
|
||
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.Cancelled, null,
|
||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
sw.Stop();
|
||
logger.LogInformation("Agent run cancelled: {AgentId}", agentConfig.AgentId);
|
||
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.Cancelled, null,
|
||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
catch (LoopLimitExceededException ex)
|
||
{
|
||
sw.Stop();
|
||
logger.LogWarning("Agent run loop limit exceeded: {AgentId}: {Message}",
|
||
agentConfig.AgentId, ex.Message);
|
||
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, null,
|
||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
sw.Stop();
|
||
logger.LogError(ex, "Agent run failed: {AgentId}", agentConfig.AgentId);
|
||
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.Failed, null,
|
||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
}
|
||
|
||
public async Task<AgentRunResult> ChatAsync(
|
||
AgentConfig agentConfig,
|
||
string userMessage,
|
||
string instanceId,
|
||
CancellationToken externalCt,
|
||
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);
|
||
RegisterRun(agentConfig.AgentId, runCts);
|
||
|
||
var gate = GetAgentGate(agentConfig.AgentId);
|
||
try
|
||
{
|
||
await gate.WaitAsync(runCts.Token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
UnregisterRun(agentConfig.AgentId, runCts);
|
||
return new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.Cancelled, "[Chat abgebrochen]",
|
||
0, 0, TimeSpan.Zero);
|
||
}
|
||
|
||
try
|
||
{
|
||
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
|
||
{
|
||
UnregisterRun(agentConfig.AgentId, runCts);
|
||
gate.Release();
|
||
}
|
||
}
|
||
|
||
private async Task<AgentRunResult> ChatCoreAsync(
|
||
AgentConfig agentConfig,
|
||
string userMessage,
|
||
string instanceId,
|
||
CancellationToken runCt,
|
||
string? source,
|
||
string runId)
|
||
{
|
||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
|
||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||
var sw = Stopwatch.StartNew();
|
||
|
||
// Die Timeout-Uhr läuft erst ab hier — Wartezeit in der Warteschlange
|
||
// darf den Lauf nicht aufzehren.
|
||
using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
|
||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(runCt, timeoutCts.Token);
|
||
var ct = linkedCts.Token;
|
||
|
||
try
|
||
{
|
||
var tools = _toolRegistry.GetForAgent(agentConfig);
|
||
var toolDefinitions = BuildToolDefinitions(tools);
|
||
|
||
List<ChatMessage> messages;
|
||
lock (_lock)
|
||
{
|
||
if (!_chatContexts.TryGetValue(agentConfig.AgentId, out messages!))
|
||
{
|
||
messages = new List<ChatMessage>();
|
||
var systemPrompt = agentConfig.FullSystemPrompt;
|
||
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
||
messages.Add(ChatMessage.System(systemPrompt));
|
||
_chatContexts[agentConfig.AgentId] = messages;
|
||
}
|
||
}
|
||
|
||
// Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt
|
||
var routedMessage = source switch
|
||
{
|
||
ChatSource.WebView => $"[WebView Chat – antworte als normaler Text, NICHT über Telegram senden]\n{userMessage}",
|
||
ChatSource.Telegram => userMessage, // Telegram-Nachrichten kommen bereits mit [Telegram] Prefix vom Job
|
||
_ => userMessage
|
||
};
|
||
|
||
messages.Add(ChatMessage.User(routedMessage));
|
||
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
|
||
|
||
string? finalMessage = null;
|
||
var tally = new TokenTally();
|
||
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
||
|
||
while (true)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
loopGuard.RecordStep();
|
||
|
||
if (cachingEnabled)
|
||
PromptCache.ApplyBreakpoints(messages);
|
||
|
||
var request = new ChatRequest
|
||
{
|
||
Model = agentConfig.Model,
|
||
Messages = messages,
|
||
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);
|
||
|
||
var promptTokens = 0;
|
||
if (response.Usage is not null)
|
||
{
|
||
tally.Add(response.Usage);
|
||
promptTokens = response.Usage.PromptTokens;
|
||
loopGuard.RecordTokens(response.Usage.TotalTokens);
|
||
}
|
||
|
||
// Context-Compaction nach API-Response prüfen
|
||
var compacted = await _compactor.CompactIfNeededAsync(
|
||
messages, promptTokens, agentConfig.LoopGuard, agentConfig.Model, ct);
|
||
if (compacted)
|
||
{
|
||
PersistChatState(agentConfig.AgentId);
|
||
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
|
||
}
|
||
|
||
var choice = response.Choices.FirstOrDefault();
|
||
if (choice is null)
|
||
{
|
||
finalMessage = "[No response from model]";
|
||
break;
|
||
}
|
||
|
||
var assistantMessage = choice.Message;
|
||
|
||
if (assistantMessage.ToolCalls is { Count: > 0 })
|
||
{
|
||
messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls));
|
||
|
||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||
{
|
||
var toolResult = await ExecuteToolCallAsync(
|
||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
|
||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||
}
|
||
|
||
OnStepCompleted?.Invoke(agentConfig.AgentId,
|
||
$"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)");
|
||
}
|
||
else
|
||
{
|
||
finalMessage = assistantMessage.Content ?? "";
|
||
messages.Add(ChatMessage.Assistant(finalMessage));
|
||
AddChatEntry(agentConfig.AgentId, "assistant", finalMessage, source);
|
||
break;
|
||
}
|
||
}
|
||
|
||
sw.Stop();
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.Completed, finalMessage,
|
||
loopGuard.Steps, tally.Total, sw.Elapsed)
|
||
{
|
||
PromptTokens = tally.Prompt,
|
||
CompletionTokens = tally.Completion,
|
||
CachedTokens = tally.Cached
|
||
};
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
sw.Stop();
|
||
var cancelMsg = "[Chat abgebrochen]";
|
||
AddChatEntry(agentConfig.AgentId, "assistant", cancelMsg, source);
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.Cancelled, cancelMsg,
|
||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
catch (LoopLimitExceededException ex)
|
||
{
|
||
sw.Stop();
|
||
var limitMsg = $"[Loop-Limit erreicht: {ex.Message}]";
|
||
logger.LogWarning("Chat loop limit: {AgentId}: {Message}", agentConfig.AgentId, ex.Message);
|
||
AddChatEntry(agentConfig.AgentId, "assistant", limitMsg, source);
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, limitMsg,
|
||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
sw.Stop();
|
||
var errorMsg = $"[Fehler: {ex.Message}]";
|
||
logger.LogError(ex, "Chat failed: {AgentId}", agentConfig.AgentId);
|
||
AddChatEntry(agentConfig.AgentId, "assistant", errorMsg, source);
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.Failed, errorMsg,
|
||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
}
|
||
|
||
// ─── Nebenläufigkeits-Helfer ───
|
||
|
||
private SemaphoreSlim GetAgentGate(string agentId)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
if (!_agentGates.TryGetValue(agentId, out var gate))
|
||
{
|
||
gate = new SemaphoreSlim(1, 1);
|
||
_agentGates[agentId] = gate;
|
||
}
|
||
return gate;
|
||
}
|
||
}
|
||
|
||
private void RegisterRun(string agentId, CancellationTokenSource cts)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
if (!_runningChats.TryGetValue(agentId, out var list))
|
||
{
|
||
list = new List<CancellationTokenSource>();
|
||
_runningChats[agentId] = list;
|
||
}
|
||
list.Add(cts);
|
||
}
|
||
}
|
||
|
||
private void UnregisterRun(string agentId, CancellationTokenSource cts)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
if (!_runningChats.TryGetValue(agentId, out var list))
|
||
return;
|
||
|
||
list.Remove(cts);
|
||
if (list.Count == 0)
|
||
_runningChats.Remove(agentId);
|
||
}
|
||
}
|
||
|
||
public IReadOnlyList<ChatEntry> GetChatHistory(string agentId)
|
||
{
|
||
lock (_lock)
|
||
return _chatHistories.TryGetValue(agentId, out var history)
|
||
? history.ToList().AsReadOnly()
|
||
: [];
|
||
}
|
||
|
||
public bool IsRunning(string agentId)
|
||
{
|
||
lock (_lock)
|
||
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.
|
||
/// Nützlich für Diagnose und Kontextgrößen-Anzeige.
|
||
/// </summary>
|
||
public IReadOnlyList<ChatMessage> GetChatContext(string agentId)
|
||
{
|
||
lock (_lock)
|
||
return _chatContexts.TryGetValue(agentId, out var ctx)
|
||
? ctx.ToList()
|
||
: [];
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bricht ALLE Chat-Läufe des Agenten ab — laufende wie wartende.
|
||
/// </summary>
|
||
public void AbortChat(string agentId)
|
||
{
|
||
List<CancellationTokenSource> toCancel;
|
||
lock (_lock)
|
||
{
|
||
if (!_runningChats.TryGetValue(agentId, out var list))
|
||
return;
|
||
toCancel = list.ToList();
|
||
}
|
||
|
||
// Außerhalb des Locks abbrechen: Cancel führt Continuations aus, die
|
||
// ihrerseits wieder auf _lock zugreifen können.
|
||
foreach (var cts in toCancel)
|
||
{
|
||
try { cts.Cancel(); }
|
||
catch (ObjectDisposedException) { /* Lauf war bereits fertig */ }
|
||
}
|
||
}
|
||
|
||
public void ClearChatHistory(string agentId)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
_chatHistories.Remove(agentId);
|
||
_chatContexts.Remove(agentId);
|
||
}
|
||
|
||
var dir = _agentDirResolver?.Invoke(agentId);
|
||
if (dir is null) return;
|
||
|
||
var historyPath = Path.Combine(dir, "ChatHistory.json");
|
||
var contextPath = Path.Combine(dir, "ChatContext.json");
|
||
if (File.Exists(historyPath)) File.Delete(historyPath);
|
||
if (File.Exists(contextPath)) File.Delete(contextPath);
|
||
}
|
||
|
||
public void SetAgentConfigProvider(
|
||
Func<IReadOnlyList<AgentConfig>> provider,
|
||
string instanceId,
|
||
Func<string, string?>? agentDirResolver = null)
|
||
{
|
||
_agentConfigProvider = provider;
|
||
_instanceId = instanceId;
|
||
_agentDirResolver = agentDirResolver;
|
||
}
|
||
|
||
public void LoadPersistedChats()
|
||
{
|
||
var configs = _agentConfigProvider?.Invoke() ?? [];
|
||
foreach (var agent in configs)
|
||
{
|
||
var dir = _agentDirResolver?.Invoke(agent.AgentId);
|
||
if (dir is null || !Directory.Exists(dir)) continue;
|
||
|
||
try
|
||
{
|
||
var historyPath = Path.Combine(dir, "ChatHistory.json");
|
||
if (File.Exists(historyPath))
|
||
{
|
||
var history = JsonSerializer.Deserialize<List<ChatEntry>>(
|
||
AtomicFile.ReadAllText(historyPath), _jsonOpts);
|
||
if (history is { Count: > 0 })
|
||
{
|
||
lock (_lock)
|
||
_chatHistories[agent.AgentId] = history;
|
||
}
|
||
}
|
||
|
||
var contextPath = Path.Combine(dir, "ChatContext.json");
|
||
if (File.Exists(contextPath))
|
||
{
|
||
var raw = AtomicFile.ReadAllText(contextPath);
|
||
List<ChatMessage>? context = null;
|
||
|
||
// Versuche zuerst als Array (direktes List<ChatMessage>)
|
||
// Dann als Wrapper-Objekt {"messages":[...]}
|
||
try
|
||
{
|
||
context = JsonSerializer.Deserialize<List<ChatMessage>>(raw, _jsonOpts);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(raw);
|
||
if (doc.RootElement.TryGetProperty("messages", out var msgs))
|
||
{
|
||
context = JsonSerializer.Deserialize<List<ChatMessage>>(
|
||
msgs.GetRawText(), _jsonOpts);
|
||
}
|
||
}
|
||
catch { /* Beide Formate fehlgeschlagen — ignorieren */ }
|
||
}
|
||
|
||
if (context is { Count: > 0 })
|
||
{
|
||
lock (_lock)
|
||
_chatContexts[agent.AgentId] = context;
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Persistence")
|
||
.LogWarning(ex, "Failed to load chat state for agent {AgentId}", agent.AgentId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── IAgentMessageRouter ───
|
||
|
||
public IReadOnlyList<AgentInfo> ListAgents(string callerAgentId)
|
||
{
|
||
var configs = _agentConfigProvider?.Invoke() ?? [];
|
||
return configs
|
||
.Where(a => a.AgentId != callerAgentId)
|
||
.Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId)))
|
||
.ToList();
|
||
}
|
||
|
||
public async Task<AgentMessageResult> SendMessageAsync(
|
||
string fromAgentId, string toAgentId, string message, CancellationToken ct)
|
||
{
|
||
var configs = _agentConfigProvider?.Invoke();
|
||
if (configs is null)
|
||
return new AgentMessageResult(false, null, "Agent config provider not set.");
|
||
|
||
// Selbstadressierung würde am Agent-Gate hängen bleiben: Der laufende Chat
|
||
// hält es bereits und würde auf sich selbst warten.
|
||
if (fromAgentId == toAgentId)
|
||
return new AgentMessageResult(false, null,
|
||
"Ein Agent kann sich keine Nachricht an sich selbst schicken.");
|
||
|
||
var targetConfig = configs.FirstOrDefault(a => a.AgentId == toAgentId);
|
||
if (targetConfig is null)
|
||
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
|
||
|
||
var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId);
|
||
var fromName = fromConfig?.DisplayName ?? fromAgentId;
|
||
|
||
var wrappedMessage = $"[Nachricht von Agent \"{fromName}\" ({fromAgentId})]\n\n{message}";
|
||
|
||
try
|
||
{
|
||
var result = await ChatAsync(targetConfig, wrappedMessage, _instanceId, ct, source: ChatSource.AgentComm);
|
||
|
||
return result.Status switch
|
||
{
|
||
AgentRunStatus.Completed => new AgentMessageResult(true, result.FinalMessage),
|
||
AgentRunStatus.LoopLimitExceeded => new AgentMessageResult(true, result.FinalMessage,
|
||
"Agent hat das Step-Limit erreicht, die Nachricht wurde aber zugestellt."),
|
||
_ => new AgentMessageResult(false, null, $"Agent run status: {result.Status}")
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return new AgentMessageResult(false, null, $"Failed to reach agent: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
public async Task<AgentSpawnResult> SpawnAgentAsync(
|
||
string fromAgentId, string targetAgentId, string taskMessage, CancellationToken ct)
|
||
{
|
||
var configs = _agentConfigProvider?.Invoke();
|
||
if (configs is null)
|
||
return new AgentSpawnResult(false, targetAgentId, null, "Agent config provider not set.");
|
||
|
||
var targetConfig = configs.FirstOrDefault(a => a.AgentId == targetAgentId);
|
||
if (targetConfig is null)
|
||
return new AgentSpawnResult(false, targetAgentId, null, $"Agent '{targetAgentId}' not found.");
|
||
|
||
if (IsRunning(targetAgentId))
|
||
return new AgentSpawnResult(false, targetAgentId, null,
|
||
$"Agent '{targetConfig.DisplayName}' läuft bereits. Verwende send_message statt spawn.");
|
||
|
||
var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId);
|
||
var fromName = fromConfig?.DisplayName ?? fromAgentId;
|
||
|
||
var wrappedMessage = $"[Spawn-Auftrag von Agent \"{fromName}\" ({fromAgentId})]\n\n{taskMessage}";
|
||
|
||
try
|
||
{
|
||
var result = await RunAsync(targetConfig, wrappedMessage, _instanceId, ct);
|
||
|
||
return result.Status == AgentRunStatus.Completed
|
||
? new AgentSpawnResult(true, targetAgentId, result.FinalMessage)
|
||
: new AgentSpawnResult(false, targetAgentId, null, $"Agent run status: {result.Status}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return new AgentSpawnResult(false, targetAgentId, null, $"Spawn failed: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private void AddChatEntry(string agentId, string role, string content, string? source = null)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
if (!_chatHistories.TryGetValue(agentId, out var history))
|
||
{
|
||
history = new List<ChatEntry>();
|
||
_chatHistories[agentId] = history;
|
||
}
|
||
history.Add(new ChatEntry(role, content, DateTime.Now, source));
|
||
}
|
||
PersistChatState(agentId);
|
||
OnChatEntryAdded?.Invoke(agentId, role, content, source);
|
||
}
|
||
|
||
private void PersistChatState(string agentId)
|
||
{
|
||
var dir = _agentDirResolver?.Invoke(agentId);
|
||
if (dir is null) return;
|
||
|
||
try
|
||
{
|
||
Directory.CreateDirectory(dir);
|
||
|
||
List<ChatEntry>? history;
|
||
List<ChatMessage>? context;
|
||
lock (_lock)
|
||
{
|
||
_chatHistories.TryGetValue(agentId, out history);
|
||
_chatContexts.TryGetValue(agentId, out context);
|
||
}
|
||
|
||
// Atomar schreiben: Ein Absturz mitten im Vorgang würde sonst den
|
||
// bisherigen Verlauf löschen und einen halben zurücklassen.
|
||
if (history is not null)
|
||
AtomicFile.WriteAllText(
|
||
Path.Combine(dir, "ChatHistory.json"),
|
||
JsonSerializer.Serialize(history, _jsonOpts));
|
||
|
||
if (context is not null)
|
||
AtomicFile.WriteAllText(
|
||
Path.Combine(dir, "ChatContext.json"),
|
||
JsonSerializer.Serialize(context, _jsonOpts));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Persistence")
|
||
.LogWarning(ex, "Failed to persist chat state for agent {AgentId}", agentId);
|
||
}
|
||
}
|
||
|
||
private async Task<string> ExecuteToolCallAsync(
|
||
ToolCall toolCall,
|
||
AgentConfig agentConfig,
|
||
string instanceId,
|
||
IReadOnlyList<IAgentTool> availableTools,
|
||
ILogger logger,
|
||
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
|
||
{
|
||
_permissionGate.Enforce(agentConfig.AgentId, toolName, agentConfig);
|
||
|
||
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 context = BuildToolContext(agentConfig, instanceId, toolName, ct);
|
||
|
||
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
|
||
|
||
var result = await tool.ExecuteAsync(input, context, ct);
|
||
|
||
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)
|
||
{
|
||
logger.LogInformation(
|
||
"Tool-Ergebnis von {Tool} gekürzt: {Original} → {Limit} Zeichen",
|
||
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. 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}" });
|
||
}
|
||
}
|
||
|
||
// ─── Budget und Verbrauchserfassung ───
|
||
|
||
/// <summary>
|
||
/// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden
|
||
/// darf — sonst null.
|
||
/// </summary>
|
||
private async Task<AgentRunResult?> CheckBudgetAsync(AgentConfig agentConfig, CancellationToken ct)
|
||
{
|
||
if (_budgetGuard is null)
|
||
return null;
|
||
|
||
var status = await _budgetGuard.CheckAsync(agentConfig, InstanceBudget, ct);
|
||
if (status.IsAllowed)
|
||
return null;
|
||
|
||
var logger = _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget");
|
||
logger.LogWarning("Lauf abgelehnt für {AgentId}: {Reason}", agentConfig.AgentId, status.Reason);
|
||
|
||
var result = new AgentRunResult(
|
||
agentConfig.AgentId, AgentRunStatus.BudgetExceeded,
|
||
$"[Budget erschöpft] {status.Reason}", 0, 0, TimeSpan.Zero);
|
||
|
||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht
|
||
/// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan.
|
||
/// </summary>
|
||
private async Task RecordUsageAsync(AgentConfig agentConfig, AgentRunResult result)
|
||
{
|
||
if (_usageRepository is null)
|
||
return;
|
||
|
||
try
|
||
{
|
||
var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
|
||
|
||
await _usageRepository.RecordAsync(new RunUsage
|
||
{
|
||
AgentId = agentConfig.AgentId,
|
||
Model = agentConfig.Model,
|
||
PromptTokens = result.PromptTokens,
|
||
CompletionTokens = result.CompletionTokens,
|
||
CachedTokens = result.CachedTokens,
|
||
CostUsd = estimate?.Usd ?? 0m,
|
||
CostIsKnown = estimate?.IsKnown ?? false,
|
||
Status = result.Status.ToString(),
|
||
StepCount = result.StepCount,
|
||
DurationMs = (long)result.Duration.TotalMilliseconds,
|
||
OccurredAt = DateTime.Now
|
||
}, CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Usage")
|
||
.LogWarning(ex, "Verbrauch konnte nicht festgehalten werden für {AgentId}",
|
||
agentConfig.AgentId);
|
||
}
|
||
}
|
||
|
||
// ─── 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
|
||
{
|
||
public int Total { get; private set; }
|
||
public int Prompt { get; private set; }
|
||
public int Completion { get; private set; }
|
||
public int Cached { get; private set; }
|
||
|
||
// Voll qualifiziert: "Usage" ist auch ein Namespace (ClawdDotNet.Core.Accounting).
|
||
public void Add(Api.Models.Usage usage)
|
||
{
|
||
Total += usage.TotalTokens;
|
||
Prompt += usage.PromptTokens;
|
||
Completion += usage.CompletionTokens;
|
||
Cached += usage.CachedTokens;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Kürzt ein Tool-Ergebnis, bevor es in den Kontext wandert.
|
||
///
|
||
/// Ohne diese Grenze kann ein einzelner Aufruf den Kontext sprengen — ein WebFetch
|
||
/// mit dem Standardlimit von 512 KB entspricht rund 130.000 Tokens in EINER
|
||
/// Tool-Antwort. Die Compaction greift erst danach, der teure Request ist zu dem
|
||
/// Zeitpunkt längst bezahlt.
|
||
/// </summary>
|
||
internal static string TruncateToolResult(string result, int maxChars)
|
||
{
|
||
if (maxChars <= 0 || result.Length <= maxChars)
|
||
return result;
|
||
|
||
var omitted = result.Length - maxChars;
|
||
return result[..maxChars] +
|
||
$"\n\n[… {omitted:N0} Zeichen gekürzt. Das Ergebnis war zu groß für den Kontext. " +
|
||
"Grenze die Abfrage ein, wenn du den Rest brauchst.]";
|
||
}
|
||
|
||
private static List<ToolDefinition> BuildToolDefinitions(IReadOnlyList<IAgentTool> tools)
|
||
{
|
||
return tools.Select(t => new ToolDefinition
|
||
{
|
||
Function = new FunctionDefinition
|
||
{
|
||
Name = t.Name,
|
||
Description = t.Description,
|
||
Parameters = t.InputSchema
|
||
}
|
||
}).ToList();
|
||
}
|
||
}
|