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.Organisation;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Scheduling;
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, Mcp.IMcpToolHost
{
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;
private readonly TimeProvider _clock;
/// Tagesgrenzen der Instanz. Wird vom Host gesetzt.
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
///
/// #716: The guard that checks and reserves every run — the scanner's headroom
/// calculation has to see the same reservations. null without usage recording.
///
public BudgetGuard? BudgetGuard => _budgetGuard;
/// #711: Who is working right now and who reached whom — for the team overview.
public Activity.AgentActivity Activity { get; } = new();
///
/// #683: The organisation tree, built fresh from the configuration on each call. Without
/// it, the instance has no structure and every agent reaches every other, as before.
///
public Func? OrganisationProvider { get; set; }
/// #689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.
public Cases.ICaseDesk? CaseDesk { get; set; }
///
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
/// daily limits; without this, every run counts against the day.
///
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; set; }
/// #654: The sandbox tools run code in.
public Sandbox.ISandboxRunner? Sandbox { get; set; }
private Organisation.OrgChart CurrentOrganisation()
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
/// The agent's system prompt with its organisation section (#683).
private string SystemPromptFor(AgentConfig agentConfig, bool hasTools)
=> Organisation.OrgBriefing.WithSection(
UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools),
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate));
///
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
///
/// Zyklen fängt schon die Kette selbst ab — dieser Wert begrenzt die Staffel, die
/// keiner ist: A beauftragt B, B beauftragt C, C beauftragt D. Jeder Schritt hält
/// das Gate seines Vorgängers und kostet einen vollen Lauf; drei Weitergaben sind
/// mehr, als eine sinnvolle Arbeitsteilung braucht.
///
/// Instanzweit und nicht je Agent: Eine Kette gehört keinem der Beteiligten.
///
public int MaxDelegationDepth { get; set; } = 3;
///
/// Wie lange ein delegierter Lauf höchstens auf das Gate seines Zielagenten
/// wartet (#461). Danach kommt eine Absage statt weiterer Wartezeit.
///
/// Die Delegationskette schließt Zyklen aus, aber nicht das Über-Kreuz: Lauf 1
/// startet bei A und beauftragt B, Lauf 2 startet zeitgleich bei B und beauftragt A.
/// Beide Ketten sind für sich zyklusfrei — trotzdem hält jeder das Gate, auf das der
/// andere wartet. Eine Kette kann das grundsätzlich nicht sehen; nur eine Frist
/// löst es auf.
///
/// Nur für delegierte Läufe. Ein vom Menschen angestoßener Chat reiht sich
/// weiterhin ein und wartet, so lange es dauert — dort gibt es niemanden, der
/// verklemmen könnte, und ein Abbruch wäre nur lästig.
///
public TimeSpan DelegationGateTimeout { get; set; } = TimeSpan.FromSeconds(120);
private readonly ContextCompactor _compactor;
private readonly Dictionary> _chatHistories = new();
private readonly Dictionary> _chatContexts = new();
private readonly Lock _lock = new();
///
/// 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.
///
private readonly Dictionary> _runningChats = new();
///
/// 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.
///
private readonly Dictionary _agentGates = new();
private Func>? _agentConfigProvider;
private Func? _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,
TimeProvider? clock = null)
{
_clock = clock ?? TimeProvider.System;
_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? OnStepCompleted;
/// #660: Where prompt and response of every run are kept. null = not at all.
public Transcripts.IRunTranscriptStore? TranscriptStore { get; init; }
///
/// #660: Instance-wide retention in days (0 = never, -1 = forever). A function, so a
/// changed setting applies to the next run without a restart.
///
public Func DefaultTranscriptRetention { get; init; } = () => Transcripts.PromptHistorySettings.DefaultRetentionDays;
///
/// 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).
///
public event Action? OnChatEntryAdded;
///
/// Wird nach jedem abgeschlossenen Run/Chat ausgelöst (model, result).
/// Erlaubt der UI, Token-Verbrauch und Kosten für ALLE Runs zu tracken.
///
public event Action? OnRunCompleted;
public async Task RunAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken externalCt,
string? source = null,
string? taskId = null,
DelegationChain? delegation = null)
{
// Vor allem anderen: Endet die Instanz gerade, faengt hier nichts Neues mehr an.
if (RefuseWhileShuttingDown(agentConfig) is { } zu)
return zu;
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
// #716: Die Reservierung gilt, bis der Verbrauch erfasst ist (Ende der Methode).
var (denied, reservation, pot) = await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt);
if (denied is not null)
return denied;
using var budgetReservation = reservation;
using var lauf = TrackRun();
var runId = Guid.NewGuid().ToString("N");
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result, pot);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId, chain.CallerRunId, pot);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
private async Task RunCoreAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken externalCt,
string runId,
string? source,
DelegationChain chain,
Transcripts.RunTranscriptRecorder recorder)
{
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);
// #578: Vor dem try, damit auch die Abbruchzweige wissen, was bis dahin bezahlt
// wurde. Vorher lag sie im try, und ein Lauf an der Schrittgrenze ging mit 0 Token
// und 0 USD in RunUsage - fuer das Tagesbudget unsichtbar.
var tally = new TokenTally();
try
{
var tools = _toolRegistry.GetForAgent(agentConfig);
var toolDefinitions = BuildToolDefinitions(tools);
var messages = new List();
// #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil.
// #683: dazu der Abschnitt zur Organisation.
var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
messages.Add(ChatMessage.User(MitZeit(userMessage, source)));
recorder.Begin(systemPrompt, messages[^1].Content ?? userMessage, userMessage);
string? finalMessage = null;
var truncated = false;
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
// #577: Eine Antwort ohne Werkzeugaufruf ist nicht automatisch der Schluss.
var istAufgabe = source == ChatSource.Task;
var leerNachgefasst = false;
var ergebnisNachgefasst = false;
var abschluss = AgentRunStatus.Completed;
// #581: gleiche Fehlschlaege zaehlen, bevor sie die Schrittgrenze aufzehren.
var fehlschlaege = new FehlschlagWache();
while (true)
{
ct.ThrowIfCancellationRequested();
loopGuard.RecordStep();
// Kompaktieren vor dem Aufruf — dieselbe Reihenfolge wie in ChatCoreAsync.
// Verhindert die Token-Explosion bei komplexen Analysen, und zwar bevor
// sie bezahlt ist statt danach.
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
{
recorder.Event("Context compacted");
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
}
if (cachingEnabled)
PromptCache.ApplyBreakpoints(messages);
var request = BuildRequest(agentConfig, messages, toolDefinitions);
var estimated = _compactor.EstimateFor(agentConfig.Model, messages);
var response = await _client.CompleteAsync(request, ct);
if (response.Usage is not null)
{
tally.Add(response.Usage);
loopGuard.RecordTokens(response.Usage.TotalTokens);
_compactor.Calibrate(agentConfig.Model, estimated, response.Usage.PromptTokens);
}
var choice = response.Choices.FirstOrDefault();
if (choice is not null)
recorder.ModelStep(choice.Message, response.Usage, choice.FinishReason);
if (choice is null)
{
finalMessage = "[No response from model]";
break;
}
var assistantMessage = choice.Message;
if (assistantMessage.ToolCalls is { Count: > 0 })
{
// #547: Der Text neben den Aufrufen geht mit. Er ist der Plan, den
// das Modell sich selbst geschrieben hat — ohne ihn sieht es im
// nächsten Schritt nur noch, DASS es etwas getan hat.
// #549: Der Denkverlauf ebenso, sonst beginnt er jedes Mal von vorn.
messages.Add(ChatMessage.AssistantWithToolCalls(
assistantMessage.ToolCalls,
assistantMessage.Content,
assistantMessage.Reasoning,
assistantMessage.ReasoningDetails));
// #552: Die Antworten gehen im finally raus. Auf jedem Ausgangspfad
// — fertig, abgebrochen, gescheitert — bekommt jeder Aufruf seine
// Antwort, sonst bleibt eine Folge zurueck, die keine API annimmt.
var toolResults = new string?[assistantMessage.ToolCalls.Count];
string? abbruch = null;
try
{
await ExecuteToolCallsAsync(
assistantMessage.ToolCalls, toolResults, agentConfig, instanceId, tools,
logger, ct, runId, source, chain);
abbruch = fehlschlaege.Pruefe(assistantMessage.ToolCalls, toolResults);
}
finally
{
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
recorder.ToolResults(assistantMessage.ToolCalls, toolResults);
}
if (abbruch is not null)
throw new WiederholterFehlschlagException(abbruch);
OnStepCompleted?.Invoke(agentConfig.AgentId,
$"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed");
}
else
{
var text = assistantMessage.Content ?? "";
var abgeschnitten = IsTruncated(choice.FinishReason);
var urteil = LaufAbschluss.Beurteile(text, abgeschnitten, istAufgabe, leerNachgefasst, ergebnisNachgefasst);
if (urteil == AbschlussUrteil.LeerNachfassen)
{
leerNachgefasst = true;
logger.LogWarning("Leere Antwort von {AgentId} nach {Steps} Schritten - einmal nachgefasst",
agentConfig.AgentId, loopGuard.Steps);
messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage));
recorder.EngineNote(LaufAbschluss.LeerNachfrage);
continue;
}
if (urteil == AbschlussUrteil.ErgebnisNachfassen)
{
ergebnisNachgefasst = true;
logger.LogInformation("Aufgabenlauf von {AgentId} ohne Ergebniszeile - einmal nachgefasst",
agentConfig.AgentId);
messages.Add(ChatMessage.Assistant(text));
messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage));
recorder.EngineNote(LaufAbschluss.ErgebnisNachfrage);
continue;
}
abschluss = StatusFuer(urteil, logger, agentConfig.AgentId);
finalMessage = text;
truncated = abgeschnitten;
break;
}
}
sw.Stop();
if (truncated)
{
logger.LogWarning(
"Agent run truncated: {AgentId} stieß nach {Steps} Schritten an die Ausgabegrenze " +
"von {Limit} Tokens. Das Ergebnis ist ein Teilergebnis.",
agentConfig.AgentId, loopGuard.Steps, agentConfig.LoopGuard.MaxResponseTokens);
}
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,
truncated ? AgentRunStatus.Truncated : abschluss,
finalMessage,
loopGuard.Steps,
tally.Total,
sw.Elapsed)
{
PromptTokens = tally.Prompt,
CompletionTokens = tally.Completion,
CachedTokens = tally.Cached,
ReportedCostUsd = tally.ReportedCost
};
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) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
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) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (WiederholterFehlschlagException ex)
{
sw.Stop();
logger.LogWarning("Agent run stopped: {AgentId}: {Message}", agentConfig.AgentId, ex.Message);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.RepeatedFailure, $"[Abgebrochen: {ex.Message}]",
loopGuard.Steps, tally.Total, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
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) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
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) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
}
public async Task ChatAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken externalCt,
string? source = null,
string? taskId = null,
DelegationChain? delegation = null)
{
if (RefuseWhileShuttingDown(agentConfig) is { } zu)
return zu;
var (denied, reservation, pot) = await CheckBudgetAsync(agentConfig, source, taskId, externalCt);
if (denied is not null)
return denied;
using var budgetReservation = reservation;
using var lauf = TrackRun();
var runId = Guid.NewGuid().ToString("N");
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
// 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);
// Ein delegierter Lauf wartet nur begrenzt (#461): Zwei Ketten über Kreuz
// halten jeweils das Gate, auf das die andere wartet, und keine der beiden
// sieht die andere. Ein vom Menschen angestoßener Chat wartet dagegen weiter,
// so lange es dauert — dort gibt es niemanden, der verklemmen könnte.
var gateWait = chain.Depth > 0 ? DelegationGateTimeout : Timeout.InfiniteTimeSpan;
try
{
if (!await gate.WaitAsync(gateWait, runCts.Token))
{
UnregisterRun(agentConfig.AgentId, runCts);
return new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.TargetBusy,
$"[Agent belegt: '{agentConfig.AgentId}' hat innerhalb von " +
$"{gateWait.TotalSeconds:0} Sekunden nicht frei gehabt]",
0, 0, TimeSpan.Zero);
}
}
catch (OperationCanceledException)
{
UnregisterRun(agentConfig.AgentId, runCts);
return new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Cancelled, "[Chat abgebrochen]",
0, 0, TimeSpan.Zero);
}
try
{
// #711: "working" starts past the gate — waiting for it is not working.
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result, pot);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId, chain.CallerRunId, pot);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
finally
{
UnregisterRun(agentConfig.AgentId, runCts);
gate.Release();
}
}
private async Task ChatCoreAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken runCt,
string? source,
string runId,
DelegationChain chain,
Transcripts.RunTranscriptRecorder recorder)
{
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;
// #578: siehe RunCoreAsync - auch Abbrueche tragen ihre bezahlten Token.
var tally = new TokenTally();
try
{
var tools = _toolRegistry.GetForAgent(agentConfig);
var toolDefinitions = BuildToolDefinitions(tools);
List messages;
lock (_lock)
{
if (!_chatContexts.TryGetValue(agentConfig.AgentId, out messages!))
{
messages = new List();
// #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren
// Systemprompt; die Regel kommt mit dem naechsten neuen Kontext.
var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
_chatContexts[agentConfig.AgentId] = messages;
}
else if (messages.Count > 0 && messages[0].Role == "system")
{
// #683: Nur der Organisationsabschnitt folgt der Struktur — nach einem
// Umzug weiss der Agent sonst im laufenden Chat nichts davon. Der Rest
// des bestehenden Systemprompts bleibt, wie er war (#193).
var section = Organisation.OrgBriefing.For(
CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate);
var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section);
if (refreshed != messages[0].Content)
messages[0] = ChatMessage.System(refreshed);
}
}
// 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(MitZeit(routedMessage, source)));
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
recorder.Begin(
messages.Count > 0 && messages[0].Role == "system" ? messages[0].Content?.ToString() : null,
messages[^1].Content ?? userMessage,
userMessage);
string? finalMessage = null;
var truncated = false;
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
// #577: Eine Antwort ohne Werkzeugaufruf ist nicht automatisch der Schluss.
var istAufgabe = source == ChatSource.Task;
var leerNachgefasst = false;
var ergebnisNachgefasst = false;
var abschluss = AgentRunStatus.Completed;
// #581: gleiche Fehlschlaege zaehlen, bevor sie die Schrittgrenze aufzehren.
var fehlschlaege = new FehlschlagWache();
while (true)
{
ct.ThrowIfCancellationRequested();
loopGuard.RecordStep();
// Kompaktieren VOR dem Aufruf. Vorher lief die Prüfung danach und mit
// den promptTokens der gerade bezahlten Anfrage: Der überfüllte Prompt
// war zu dem Zeitpunkt längst abgerechnet, gekürzt wurde erst für den
// nächsten Schritt. Genau ein Schritt zu spät, und der teuerste.
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
{
recorder.Event("Context compacted");
PersistChatState(agentConfig.AgentId);
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
}
if (cachingEnabled)
PromptCache.ApplyBreakpoints(messages);
var request = BuildRequest(agentConfig, messages, toolDefinitions);
// Was die Schätzung für genau diese Anfrage sagt — gleich neben dem, was
// die API dafür wirklich berechnet. Der Vergleich der beiden ist die
// einzige Rückmeldung, die die Schätzung je bekommt.
var estimated = _compactor.EstimateFor(agentConfig.Model, messages);
var response = await _client.CompleteAsync(request, ct);
if (response.Usage is not null)
{
tally.Add(response.Usage);
loopGuard.RecordTokens(response.Usage.TotalTokens);
_compactor.Calibrate(agentConfig.Model, estimated, response.Usage.PromptTokens);
}
var choice = response.Choices.FirstOrDefault();
if (choice is not null)
recorder.ModelStep(choice.Message, response.Usage, choice.FinishReason);
if (choice is null)
{
finalMessage = "[No response from model]";
break;
}
var assistantMessage = choice.Message;
if (assistantMessage.ToolCalls is { Count: > 0 })
{
// #547 / #549 — siehe RunCoreAsync: Begleittext und Denkverlauf
// gehören in den Verlauf, nicht in den Papierkorb.
messages.Add(ChatMessage.AssistantWithToolCalls(
assistantMessage.ToolCalls,
assistantMessage.Content,
assistantMessage.Reasoning,
assistantMessage.ReasoningDetails));
// #552: Die Antworten gehen im finally raus. Auf jedem Ausgangspfad
// — fertig, abgebrochen, gescheitert — bekommt jeder Aufruf seine
// Antwort, sonst bleibt eine Folge zurueck, die keine API annimmt.
var toolResults = new string?[assistantMessage.ToolCalls.Count];
string? abbruch = null;
try
{
await ExecuteToolCallsAsync(
assistantMessage.ToolCalls, toolResults, agentConfig, instanceId, tools,
logger, ct, runId, source, chain);
abbruch = fehlschlaege.Pruefe(assistantMessage.ToolCalls, toolResults);
}
finally
{
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
recorder.ToolResults(assistantMessage.ToolCalls, toolResults);
}
if (abbruch is not null)
throw new WiederholterFehlschlagException(abbruch);
OnStepCompleted?.Invoke(agentConfig.AgentId,
$"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)");
}
else
{
var text = assistantMessage.Content ?? "";
var abgeschnitten = IsTruncated(choice.FinishReason);
var urteil = LaufAbschluss.Beurteile(text, abgeschnitten, istAufgabe, leerNachgefasst, ergebnisNachgefasst);
// #577 - siehe RunCoreAsync. Die Nachfrage selbst steht nur im Kontext,
// nicht im sichtbaren Verlauf: Sie ist kein Beitrag des Menschen.
if (urteil == AbschlussUrteil.LeerNachfassen)
{
leerNachgefasst = true;
logger.LogWarning("Leere Antwort von {AgentId} nach {Steps} Schritten - einmal nachgefasst",
agentConfig.AgentId, loopGuard.Steps);
messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage));
recorder.EngineNote(LaufAbschluss.LeerNachfrage);
continue;
}
if (urteil == AbschlussUrteil.ErgebnisNachfassen)
{
ergebnisNachgefasst = true;
logger.LogInformation("Aufgabenlauf von {AgentId} ohne Ergebniszeile - einmal nachgefasst",
agentConfig.AgentId);
messages.Add(ChatMessage.Assistant(text));
AddChatEntry(agentConfig.AgentId, "assistant", text, source);
messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage));
recorder.EngineNote(LaufAbschluss.ErgebnisNachfrage);
continue;
}
abschluss = StatusFuer(urteil, logger, agentConfig.AgentId);
finalMessage = text;
truncated = abgeschnitten;
var imVerlauf = string.IsNullOrWhiteSpace(text) ? LaufAbschluss.LeerePlatzhalter : text;
messages.Add(ChatMessage.Assistant(imVerlauf));
AddChatEntry(agentConfig.AgentId, "assistant", imVerlauf, source);
break;
}
}
sw.Stop();
if (truncated)
{
logger.LogWarning(
"Chat truncated: {AgentId} stieß an die Ausgabegrenze von {Limit} Tokens. " +
"Die Antwort ist ein Teilergebnis.",
agentConfig.AgentId, agentConfig.LoopGuard.MaxResponseTokens);
}
var result = new AgentRunResult(
agentConfig.AgentId,
truncated ? AgentRunStatus.Truncated : abschluss,
finalMessage,
loopGuard.Steps, tally.Total, sw.Elapsed)
{
PromptTokens = tally.Prompt,
CompletionTokens = tally.Completion,
CachedTokens = tally.Cached,
ReportedCostUsd = tally.ReportedCost
};
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) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (WiederholterFehlschlagException ex)
{
sw.Stop();
var stopMsg = $"[Abgebrochen: {ex.Message}]";
logger.LogWarning("Chat stopped: {AgentId}: {Message}", agentConfig.AgentId, ex.Message);
AddChatEntry(agentConfig.AgentId, "assistant", stopMsg, source);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.RepeatedFailure, stopMsg,
loopGuard.Steps, tally.Total, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
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) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
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) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
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();
_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 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);
}
/// Anzahl Agenten mit mindestens einem aktiven Chat-Lauf — für Diagnose/Heartbeat.
public int RunningChatCount
{
get { lock (_lock) return _runningChats.Count; }
}
// ─── Geordnetes Ende (#528) ───
private int _openRuns;
private int _accepting = 1;
///
/// Wie viele Agentenläufe gerade laufen — Chats und angestoßene Läufe.
///
/// Nicht dasselbe wie : Der zählt Agenten
/// mit mindestens einem Chat, dieser zählt Läufe. Fürs Herunterfahren ist der
/// Lauf die Einheit — zwei Läufe desselben Agenten sind zwei Dinge, die zu Ende kommen
/// müssen.
///
public int OpenRunCount => Volatile.Read(ref _openRuns);
/// Nimmt die Engine noch neue Läufe an?
public bool IsAcceptingWork => Volatile.Read(ref _accepting) == 1;
///
/// Schließt die Tür: ab jetzt kein neuer Lauf mehr.
///
/// Laufende werden nicht abgebrochen — das ist der ganze Punkt. Wer sie
/// abbrechen will, hat dafür AbortChat.
///
public void StopAccepting() => Volatile.Write(ref _accepting, 0);
private AgentRunResult? RefuseWhileShuttingDown(AgentConfig agentConfig)
=> IsAcceptingWork
? null
: new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.ShuttingDown,
"[Die Instanz endet gerade — es werden keine neuen Läufe mehr angenommen]",
0, 0, TimeSpan.Zero);
///
/// Zählt einen laufenden Lauf, solange sie lebt.
///
/// Als using-Wert statt als try/finally von Hand, weil beide
/// Einstiege mehrere Rückgabewege haben und ein vergessener Weg den Zähler dauerhaft
/// verschöbe — eine Instanz, die dann nie mehr enden könnte, weil ein Lauf zu Buche
/// steht, den es nicht gibt.
///
private RunScope TrackRun() => new(this);
private readonly struct RunScope : IDisposable
{
private readonly AgentEngine _engine;
public RunScope(AgentEngine engine)
{
_engine = engine;
Interlocked.Increment(ref engine._openRuns);
}
public void Dispose() => Interlocked.Decrement(ref _engine._openRuns);
}
///
/// 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.
///
public IReadOnlyList GetChatContext(string agentId)
{
lock (_lock)
return _chatContexts.TryGetValue(agentId, out var ctx)
? ctx.ToList()
: [];
}
///
/// Bricht ALLE Chat-Läufe des Agenten ab — laufende wie wartende.
///
public void AbortChat(string agentId)
{
List 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> provider,
string instanceId,
Func? 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>(
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? context = null;
// Versuche zuerst als Array (direktes List)
// Dann als Wrapper-Objekt {"messages":[...]}
try
{
context = JsonSerializer.Deserialize>(raw, _jsonOpts);
}
catch (JsonException)
{
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.TryGetProperty("messages", out var msgs))
{
context = JsonSerializer.Deserialize>(
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 ───
///
/// Darf dieser Aufruf weitergegeben werden (B8)? Gibt den Ablehnungsgrund zurück
/// oder null, wenn nichts dagegen spricht.
///
/// Der Grund geht als Ergebnis an den aufrufenden Agenten zurück, nicht als
/// Ausnahme: Er soll lesen können, warum es nicht geht, und einen anderen Weg
/// wählen. Deshalb steht die ganze Kette in der Meldung — „Zyklus" allein sagt
/// niemandem, welcher.
///
private string? CheckDelegation(DelegationChain? chain, string fromAgentId, string toAgentId)
{
// Ohne Kette ist der Aufruf von aussen angestossen: Der Absender ist der erste
// in der Kette, weitergegeben wurde noch nichts.
var callerChain = chain ?? DelegationChain.Root(fromAgentId);
// Der eigentliche Deadlock. Der Zielagent hält sein Gate schon irgendwo weiter
// oben in dieser Kette und gibt es erst frei, wenn dieser Aufruf zurückkommt —
// was er nur könnte, wenn das Gate frei wäre.
if (callerChain.Contains(toAgentId))
return $"Zyklus in der Delegationskette: {callerChain} -> {toAgentId}. " +
$"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " +
"kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren.";
// #683: Die Grenze folgt der Tiefe des Baums; der eingestellte Wert bleibt Untergrenze.
var maxDepth = CurrentOrganisation().DelegationDepth(MaxDelegationDepth);
if (callerChain.Depth >= maxDepth)
return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " +
$"{maxDepth} Weitergaben erreicht. Erledige den Rest selbst " +
"oder gib die Aufgabe an den Auftraggeber zurück.";
return null;
}
///
/// #683: The address book, enforced — not merely described in the prompt. A refused
/// contact is an answer to the caller, naming the way that is open: its lead.
///
private string? CheckAddressBook(string fromAgentId, string toAgentId)
{
var org = CurrentOrganisation();
if (org.CanAddress(fromAgentId, toAgentId))
return null;
var lead = org.EffectiveLeaderOf(fromAgentId);
return $"'{toAgentId}' is not in your address book: from your position in the organisation you " +
"cannot reach this agent directly. " +
(lead is not null
? $"Hand the matter to your lead '{lead.AgentId}', who can pass it on."
: "Create a task for @human if it needs someone outside your reach.");
}
///
/// #715: The way up. An agent that reads foreign content writes nothing up the line, and
/// nobody writes past its lead — the refusal names the way that is open.
///
private string? CheckReportingLine(AgentConfig? fromConfig, string fromAgentId, string toAgentId)
=> ReportingLine.CheckUpward(CurrentOrganisation(), TrustOf(fromConfig), fromAgentId, toAgentId, UpwardKind.Message);
/// #689: Whether agents can raise a case — the desk is set up and its tool registered.
private bool CanEscalate => CaseDesk is not null && _toolRegistry.Get(Cases.CaseDesk.ToolName) is not null;
private AgentTrust TrustOf(AgentConfig? agent)
=> agent is null ? AgentTrust.Standard : ReportingLine.TrustOf(agent, _toolRegistry.Get);
public IReadOnlyList ListAgents(string callerAgentId)
{
var configs = _agentConfigProvider?.Invoke() ?? [];
// #683: Only the agent's address book — the cut of the organisation.
var reachable = CurrentOrganisation().AddressBook(callerAgentId);
return configs
.Where(a => a.AgentId != callerAgentId && reachable.Contains(a.AgentId))
.Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId)))
.ToList();
}
///
/// Die Instanz des Aufrufers, sonst die der Engine (B13).
///
/// _instanceId wird nur in SetAgentConfigProvider gesetzt und war
/// vorher die einzige Quelle — ein Lauf, der mit einer anderen Instanz-Id
/// angestoßen wurde, verlor sie beim Weiterreichen, und ohne den Aufruf war sie leer.
///
private string ResolveInstanceId(string? fromCaller)
=> string.IsNullOrWhiteSpace(fromCaller) ? _instanceId : fromCaller;
public async Task SendMessageAsync(
string fromAgentId, string toAgentId, string message,
string? instanceId, DelegationChain? chain, 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.");
if (CheckAddressBook(fromAgentId, toAgentId) is { } outOfReach)
return new AgentMessageResult(false, null, outOfReach);
if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused)
return new AgentMessageResult(false, null, refused);
var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId);
if (CheckReportingLine(fromConfig, fromAgentId, toAgentId) is { } notUp)
return new AgentMessageResult(false, null, notUp);
var callerChain = chain ?? DelegationChain.Root(fromAgentId);
var fromName = fromConfig?.DisplayName ?? fromAgentId;
// #715: What an agent of the foreign tier writes arrives as data, not as an order.
var wrappedMessage = $"[Nachricht von Agent \"{fromName}\" ({fromAgentId})]\n\n"
+ ReportingLine.Frame(TrustOf(fromConfig), fromAgentId, message);
try
{
var result = await ChatAsync(targetConfig, wrappedMessage, ResolveInstanceId(instanceId), ct,
source: ChatSource.AgentComm, delegation: callerChain.Append(toAgentId));
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."),
// #461: Das Ziel war die ganze Frist über belegt. Für den fragenden
// Agenten ist das kein Fehler, sondern eine Auskunft — er kann es
// später erneut versuchen oder ohne die Antwort weiterarbeiten.
AgentRunStatus.TargetBusy => new AgentMessageResult(false, null,
$"Agent '{targetConfig.DisplayName}' ({toAgentId}) ist belegt und hat innerhalb " +
$"von {DelegationGateTimeout.TotalSeconds:0} Sekunden nicht frei gehabt. " +
"Später erneut versuchen oder ohne diese Antwort weitermachen."),
_ => 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 SpawnAgentAsync(
string fromAgentId, string targetAgentId, string taskMessage,
string? instanceId, DelegationChain? chain, 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.");
// Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits
// delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen.
if (CheckAddressBook(fromAgentId, targetAgentId) is { } outOfReach)
return new AgentSpawnResult(false, targetAgentId, null, outOfReach);
if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused)
return new AgentSpawnResult(false, targetAgentId, null, refused);
var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId);
if (CheckReportingLine(fromConfig, fromAgentId, targetAgentId) is { } notUp)
return new AgentSpawnResult(false, targetAgentId, null, notUp);
var callerChain = chain ?? DelegationChain.Root(fromAgentId);
if (IsRunning(targetAgentId))
return new AgentSpawnResult(false, targetAgentId, null,
$"Agent '{targetConfig.DisplayName}' läuft bereits. Verwende send_message statt spawn.");
var fromName = fromConfig?.DisplayName ?? fromAgentId;
var wrappedMessage = $"[Spawn-Auftrag von Agent \"{fromName}\" ({fromAgentId})]\n\n"
+ ReportingLine.Frame(TrustOf(fromConfig), fromAgentId, taskMessage);
try
{
var result = await RunAsync(targetConfig, wrappedMessage, ResolveInstanceId(instanceId), ct,
delegation: callerChain.Append(targetAgentId));
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();
_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? history;
List? 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);
}
}
///
/// Ob das Modell abgebrochen wurde, statt fertig zu werden (#548).
///
/// Der Grund steht in finish_reason. Anbieter schreiben ihn verschieden:
/// length ist die verbreitete Form, max_tokens kommt bei einigen
/// vor. Beides meint dasselbe — die Ausgabegrenze war erreicht, bevor der Gedanke
/// zu Ende war.
///
/// Fehlt das Feld ganz, wird nicht von Abbruch ausgegangen. Ein Anbieter, der
/// nichts meldet, soll keine Warnung auslösen, die es nicht gibt.
///
internal static bool IsTruncated(string? finishReason) =>
finishReason is not null &&
(finishReason.Equals("length", StringComparison.OrdinalIgnoreCase) ||
finishReason.Equals("max_tokens", StringComparison.OrdinalIgnoreCase));
///
/// Baut die Anfrage für einen Schritt. Eine Stelle für beide Schleifen — als das
/// hier noch zweimal dastand, war der Unterschied zwischen ihnen niemandes Absicht,
/// sondern das Ergebnis davon, dass eine Änderung nur an einer Stelle ankam.
///
private static ChatRequest BuildRequest(
AgentConfig agentConfig,
List messages,
List toolDefinitions) => new()
{
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,
// #550: Nur an Anbieter weiterreichen, die alles beherrschen, was mitgeht.
Provider = agentConfig.RequireProviderParameters
? new ProviderPreferences { RequireParameters = true }
: null,
// #550: Den abgerechneten Betrag mitliefern lassen, statt ihn zu schätzen.
Usage = new UsageRequest { Include = true },
Reasoning = agentConfig.BuildReasoningRequest()
};
///
/// Führt die Werkzeugaufrufe eines Zuges aus und liefert die Ergebnisse
/// in der Reihenfolge der Aufrufe zurück — unabhängig davon, welcher zuerst
/// fertig wurde. Daran hängt zweierlei: die Zuordnung über die tool_call_id und der
/// Prompt-Cache, dessen Präfix bei wechselnder Reihenfolge jedes Mal neu wäre.
///
/// Als nebenläufig gekennzeichnete Aufrufe laufen gemeinsam, alle übrigen
/// nacheinander. Gemischt heißt: erst die gemeinsame Gruppe, dann der Rest — ein
/// schreibendes Werkzeug sieht damit nie einen halbfertigen Zustand eines anderen.
///
///
/// Ersatzantwort für einen Aufruf, der nie zu Ende kam (#552). Ohne sie bliebe der
/// Aufruf unbeantwortet, und eine solche Folge nimmt die Schnittstelle nicht an.
///
internal const string AbgebrochenerAufruf =
"""{"error":"Aufruf abgebrochen, bevor ein Ergebnis vorlag."}""";
private async Task ExecuteToolCallsAsync(
IReadOnlyList toolCalls,
string?[] results,
AgentConfig agentConfig,
string instanceId,
IReadOnlyList availableTools,
ILogger logger,
CancellationToken ct,
string runId,
string? source,
DelegationChain chain)
{
var limit = Math.Max(1, agentConfig.MaxParallelToolCalls);
// Nebenläufig ist ein Aufruf nur, wenn das Werkzeug sich dafür freigegeben hat.
// Ein unbekannter Name gilt als nicht freigegeben — ExecuteToolCallAsync macht
// daraus ohnehin einen Fehler, und der gehört in die serielle Reihe.
var parallel = new List();
var serial = new List();
for (var i = 0; i < toolCalls.Count; i++)
{
var tool = availableTools.FirstOrDefault(t => t.Name == toolCalls[i].Function.Name);
(tool?.IsParallelSafe == true && limit > 1 ? parallel : serial).Add(i);
}
if (parallel.Count == 1)
{
// Eine einzelne Aufgabe nebenläufig zu starten, kostet nur Umstand.
serial.Insert(0, parallel[0]);
parallel.Clear();
}
if (parallel.Count > 0)
{
logger.LogDebug("{Count} Werkzeuge laufen gemeinsam (Grenze {Limit})", parallel.Count, limit);
using var slots = new SemaphoreSlim(limit, limit);
// WhenAll statt einer Schleife mit Abbruch: Jede Aufgabe muss abgewartet
// werden, auch wenn eine andere bereits geworfen hat. Sonst läuft ein
// Werkzeug im Hintergrund weiter, während der Lauf sich schon beendet hat,
// und seine Ausnahme taucht später aus dem Nichts auf.
await Task.WhenAll(parallel.Select(async index =>
{
await slots.WaitAsync(ct);
try
{
results[index] = await ExecuteToolCallAsync(
toolCalls[index], agentConfig, instanceId, availableTools,
logger, ct, runId, source, chain);
}
finally
{
slots.Release();
}
}));
}
foreach (var index in serial)
{
results[index] = await ExecuteToolCallAsync(
toolCalls[index], agentConfig, instanceId, availableTools,
logger, ct, runId, source, chain);
}
}
///
/// Hängt für jeden Aufruf eine Antwort an — auch für den, der nie zu Ende kam
/// (#552).
///
/// Wird aus einem finally gerufen. Der Kontext ist bei einem Chat die
/// geteilte, an Ort und Stelle veränderte Liste; bliebe hier eine Lücke, stünde
/// darin dauerhaft eine Folge, die die Schnittstelle mit HTTP 400 ablehnt — und
/// über PersistChatState wanderte sie zuverlässig auf die Platte. Der Agent
/// wäre danach auch nach einem Neustart nicht mehr ansprechbar.
///
private static void AppendToolResponses(
List messages,
IReadOnlyList toolCalls,
string?[] results)
{
for (var i = 0; i < toolCalls.Count; i++)
messages.Add(ChatMessage.ToolResponse(toolCalls[i].Id, results[i] ?? AbgebrochenerAufruf));
}
private async Task ExecuteToolCallAsync(
ToolCall toolCall,
AgentConfig agentConfig,
string instanceId,
IReadOnlyList availableTools,
ILogger logger,
CancellationToken ct,
string runId,
string? source,
DelegationChain chain,
bool external = false)
{
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, toolCall.Id);
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."));
}
// #581: Unvollstaendiges Argument-JSON gar nicht erst ans Tool reichen. Vorher
// bekam das Modell den Parsertext ("Expected depth to be zero...") und
// wiederholte denselben Aufruf bis zur Schrittgrenze. Beobachtet bei sehr
// grossen Argumenten: Das JSON brach genau vor dem grossen Wert ab.
if (!string.IsNullOrWhiteSpace(arguments) && !IstVollstaendigesJson(arguments))
{
var hinweis = UnvollstaendigeArgumente(toolName, arguments, agentConfig.LoopGuard.MaxResponseTokens);
logger.LogWarning("Unvollstaendige Argumente fuer {Tool} ({Laenge} Zeichen)", toolName, arguments.Length);
await Audit(AuditStatus.Error, hinweis);
return JsonSerializer.Serialize(new { error = hinweis });
}
var input = string.IsNullOrWhiteSpace(arguments)
? default
: JsonDocument.Parse(arguments).RootElement;
var context = BuildToolContext(agentConfig, instanceId, toolName, ct, chain, runId);
// #713: What happens inside a foreign runtime, no check of ours sees. Whatever it
// sends through the MCP server comes from the less trusted tier — the way up is
// the one a reader of foreign content has (#715).
if (external)
context = context with { Trust = ExternalTrust(context.Trust) };
// #579: Was das Werkzeug ohnehin verbieten wuerde, kommt gar nicht erst in die
// Freigabeliste. Vorher lag dort z.B. ein Loeschvorschlag eines Agenten, der
// nur lesen darf - der Mensch entschied ueber etwas, das nie passieren konnte.
if (tool.Preflight(input, context) is { } verweigert)
{
logger.LogInformation("Vorpruefung von {Tool} lehnt ab: {Grund}", toolName, verweigert);
await Audit(AuditStatus.Denied, verweigert);
return WerkzeugFehler.Json(verweigert);
}
// Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt
// ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
if (_stagingGate is not null)
{
// #641: Die Rechte stehen in der Werkzeugkonfiguration des Agenten.
var operation = tool.StagingAktion(input) ?? Staging.StagingGate.ExtractAction(arguments);
var intercept = await _stagingGate.InterceptAsync(
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
operation,
Staging.Werkzeugrechte.Aus(context.ToolConfig),
operation is null ? null : tool.Vorgabe(operation, context.ToolConfig));
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
}
context = context with { FreigabeGeprueft = true };
}
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
// #554: Vor der Wirkung vermerken, nicht danach. Stirbt die Anwendung
// mitten im Aufruf, ist das hier die einzige Spur, die davon übrig bleibt —
// ein Eintrag nach der Ausführung wäre genau dann nie geschrieben worden.
// Steht bewusst nach Rechteprüfung und Staging: Was gar nicht erst läuft,
// hinterlässt auch nichts aufzuräumen.
await Audit(AuditStatus.Started, "");
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, "");
// #193: Was ein Werkzeug liefert, ist Datenmaterial - gerahmt, damit das Modell
// es von Anweisungen unterscheiden kann (Regel steht im Systemprompt).
return UntrustedContent.Rahmen(toolName, 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.
//
// Sehr wohl aber ins Protokoll (#554). Früher stand hier "auch kein Audit",
// und das war richtig, solange es keinen Eintrag vor dem Aufruf gab. Jetzt
// gibt es ihn: Bliebe er ohne Abschluss, sähe jeder gewöhnliche Abbruch beim
// nächsten Start aus wie ein Absturz. Die Wirkung bleibt in beiden Fällen
// ungewiss — nur ist sie hier wenigstens erklärt.
//
// CancellationToken.None, weil der reguläre Token gerade abgebrochen wurde
// und das Schreiben sonst selbst mit abbräche.
await RecordAuditAsync(runId, agentConfig, source, toolName, arguments,
AuditStatus.Cancelled, "Unterbrochen, während der Aufruf lief.",
sw.ElapsedMilliseconds, toolCall.Id);
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}" });
}
}
///
/// Stellt einer Nachricht die aktuelle Zeit voran (#589, Teil 2). Aufgaben bringen sie
/// schon aus dem Dispatcher mit - dort samt Termin -, alle anderen Quellen bekommen sie
/// hier. Im Nachrichtentext, nicht im Systemprompt, damit der Prompt-Cache greift.
///
private string MitZeit(string nachricht, string? source)
=> source == ChatSource.Task
? nachricht
: ZeitAngabe.Zeile(_clock.GetUtcNow()) + "\n\n" + nachricht;
/// Der Endstatus zu einem abschliessenden Urteil (#577), mit Warnung, wenn es kein Erfolg ist.
private static AgentRunStatus StatusFuer(AbschlussUrteil urteil, ILogger logger, string agentId)
{
switch (urteil)
{
case AbschlussUrteil.Leer:
logger.LogWarning("Lauf von {AgentId} endet ohne Antwort - auch nach dem Nachfassen leer", agentId);
return AgentRunStatus.EmptyResponse;
case AbschlussUrteil.Unvollstaendig:
logger.LogWarning("Aufgabenlauf von {AgentId} endet ohne Ergebniszeile - gilt als unvollstaendig", agentId);
return AgentRunStatus.Incomplete;
default:
return AgentRunStatus.Completed;
}
}
/// Ob die Argumente eines Werkzeugaufrufs vollstaendiges JSON sind (#581).
internal static bool IstVollstaendigesJson(string arguments)
{
try
{
using var _ = JsonDocument.Parse(arguments);
return true;
}
catch (JsonException)
{
return false;
}
}
///
/// Die Rueckmeldung ans Modell, wenn Argumente abgeschnitten ankamen (#581): was los
/// ist und was stattdessen zu tun ist - nicht der Text eines JSON-Parsers.
///
internal static string UnvollstaendigeArgumente(string toolName, string arguments, int maxResponseTokens)
{
var ende = arguments.Length <= 40 ? arguments : "..." + arguments[^40..];
return $"Die Argumente fuer '{toolName}' sind kein vollstaendiges JSON ({arguments.Length} Zeichen, "
+ $"enden mit: {ende}). Meist war ein Argument zu gross fuer eine einzelne Antwort "
+ $"(Ausgabegrenze {maxResponseTokens} Tokens) und wurde abgeschnitten. Denselben Aufruf "
+ "nicht wiederholen - den Inhalt auf mehrere kleinere Aufrufe verteilen.";
}
/// #713: The trust of a call from a foreign runtime — never the standard tier.
private static AgentTrust ExternalTrust(AgentTrust trust)
=> new(TrustTier.ReadsForeignContent, [.. trust.Sources, "external-runtime"]);
///
/// #713: The tools an agent has — what the MCP server offers it. The same list a native
/// run of the agent gets, implicit ones included; nothing beyond it.
///
public IReadOnlyList ToolsFor(string agentId)
=> _agentConfigProvider?.Invoke().FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase)) is { } agent
? _toolRegistry.GetForAgent(agent)
: [];
///
/// #713: A tool call from outside the model loop — a foreign runtime or a human's client,
/// through the MCP server. It takes the same way as a call of the agent's own model:
/// permission gate, preflight, tool rights, staging, audit, framing. No shortcut, and
/// the caller counts as reading foreign content.
///
public async Task<(string Text, bool IsError)> ExecuteExternalToolCallAsync(
string agentId, string runId, string toolName, string argumentsJson, CancellationToken ct)
{
var agent = _agentConfigProvider?.Invoke()
.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase));
if (agent is null)
return ($"Agent '{agentId}' does not exist (anymore).", true);
var call = new ToolCall
{
Id = "mcp_" + Guid.NewGuid().ToString("N")[..12],
Type = "function",
Function = new ToolCallFunction { Name = toolName, Arguments = argumentsJson }
};
var logger = _loggerFactory.CreateLogger("ClawdDotNet.Core.Mcp");
var text = await ExecuteToolCallAsync(
call, agent, _instanceId, _toolRegistry.GetForAgent(agent), logger, ct, runId,
AuditSource.Mcp, DelegationChain.Root(agent.AgentId), external: true);
return (text, IsErrorResult(text));
}
/// Errors come back as {"error": …} — everything else is a result.
private static bool IsErrorResult(string text)
{
if (!text.TrimStart().StartsWith('{'))
return false;
try
{
using var doc = JsonDocument.Parse(text);
return doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("error", out _);
}
catch (JsonException)
{
return false;
}
}
private AgentToolContext BuildToolContext(
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct,
DelegationChain? chain = null, string? runId = null)
{
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
: new Dictionary().AsReadOnly();
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
var baseChain = chain ?? DelegationChain.Root(agentConfig.AgentId);
return new AgentToolContext(
agentConfig.AgentId,
instanceId,
toolConfig,
_stateStore,
toolLogger,
ct,
agentConfig.WorkspacePath,
agentConfig.SharedWorkspacePath,
this,
_memoryRepository,
_taskRepository,
// #697: The chain carries the caller's run, so a delegated run can name where it came from.
runId is null ? baseChain : baseChain.WithRun(runId))
{
Organisation = CurrentOrganisation(),
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
Cases = CaseDesk,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
RunId = runId,
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
};
}
///
/// 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).
///
public async Task 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;
// #641: Ein Mensch hat genau diesen Aufruf freigegeben.
var context = BuildToolContext(config, _instanceId, tool, ct, runId: runId) with { FreigabeGeprueft = true };
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 ───
///
/// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden
/// darf — sonst null und, bei gesetzter Grenze, die Reservierung des Laufs (#716).
/// #717: Liegt die Aufgabe in einem Baum mit Sonderbudget, zieht der Lauf den Topf statt
/// der Tagesgrenzen; dessen Kennung kommt als Pot zurück.
///
private async Task<(AgentRunResult? Denied, IDisposable? Reservation, string? Pot)> CheckBudgetAsync(
AgentConfig agentConfig, string? source, string? taskId, CancellationToken ct)
{
if (_budgetGuard is null)
return (null, null, null);
if (await ReservePotAsync(agentConfig, taskId, ct) is { } potRun)
return (null, potRun.Reservation, potRun.Pot);
var (status, reservation) = await _budgetGuard.ReserveAsync(agentConfig, InstanceBudget, ct);
if (status.IsAllowed)
return (null, reservation, null);
// #608: Ist ein Budget erschoepft, lehnt es jeden weiteren Lauf des Tages ab. Einmal je
// Agent, Grenze und Tag als Warnung genuegt - danach steht jede Ablehnung als Beleg da.
var logger = _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget");
var heute = DateOnly.FromDateTime(_clock.GetLocalNow().DateTime);
var schluessel = agentConfig.AgentId + "|" + status.Limit;
if (_budgetGewarnt.TryGetValue(schluessel, out var gewarnt) && gewarnt == heute)
{
logger.LogInformation("Lauf abgelehnt für {AgentId} (Aufgabe {TaskId}): {Reason}",
agentConfig.AgentId, taskId ?? "-", status.Reason);
}
else
{
_budgetGewarnt[schluessel] = heute;
logger.LogWarning("Lauf abgelehnt für {AgentId} (Aufgabe {TaskId}): {Reason} Weitere Ablehnungen heute stehen als Beleg im Verlauf.",
agentConfig.AgentId, taskId ?? "-", status.Reason);
}
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.BudgetExceeded,
$"[Budget erschöpft] {status.Reason}", 0, 0, TimeSpan.Zero);
// #608: Ohne Beleg blieb die Ablehnung unsichtbar - Aufgabenkarte und Verlauf zeigten
// weiter den letzten erfolgreichen Lauf, die Luecke fiel nur im Log auf.
await RecordReceiptAsync(Guid.NewGuid().ToString("N"), agentConfig, result, source, taskId);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return (result, null, null);
}
///
/// #717: The special budget of the task's tree, if there is one and it has something left.
/// A used-up pot is not a refusal — the tree's runs count against the day again, like any
/// other work, and that is said once in the log.
///
private async Task<(IDisposable Reservation, string Pot)?> ReservePotAsync(
AgentConfig agentConfig, string? taskId, CancellationToken ct)
{
if (_budgetGuard is null || SpecialBudgets is not { } pots || _taskRepository is null || string.IsNullOrWhiteSpace(taskId))
return null;
try
{
if (await Budget.SpecialBudgets.FindAsync(taskId, _taskRepository, pots, ct) is not { } pot)
return null;
var spent = await pots.SpentAsync(pot.TaskId, ct);
if (await _budgetGuard.ReservePotAsync(pot, spent, agentConfig.AgentId, ct) is { } reservation)
return (reservation, pot.TaskId);
if (_potUsedUp.TryAdd(pot.TaskId, 0))
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget").LogWarning(
"Special budget of {TaskId} is used up ({Spent:F2} of {Amount:F2} USD) — runs in its tree count against the daily budget again",
pot.TaskId, spent, pot.AmountUsd);
return null;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Without the pot the run is checked like any other — never unchecked.
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget")
.LogWarning(ex, "Special budget lookup failed for task {TaskId}", taskId);
return null;
}
}
/// #717: Pots already reported as used up — said once, not at every run.
private readonly System.Collections.Concurrent.ConcurrentDictionary _potUsedUp = new(StringComparer.OrdinalIgnoreCase);
/// Welche Budgetgrenze je Agent zuletzt an welchem Tag als Warnung gemeldet wurde.
private readonly System.Collections.Concurrent.ConcurrentDictionary _budgetGewarnt = new();
///
/// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht
/// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan.
///
private async Task RecordUsageAsync(AgentConfig agentConfig, AgentRunResult result, string? budgetPot = null)
{
if (_usageRepository is null)
return;
try
{
var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
// #550: Der gemeldete Betrag schlägt die Eigenrechnung. Er weiß von
// Cache-Aufschlägen und anbieterabhängigen Preisen, die eine Preisliste
// nicht abbildet. Fehlt er, bleibt die Schätzung als Rückfallebene —
// deshalb wird sie weiterhin unverändert berechnet.
var reported = result.ReportedCostUsd;
await _usageRepository.RecordAsync(new RunUsage
{
AgentId = agentConfig.AgentId,
Model = agentConfig.Model,
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens,
CachedTokens = result.CachedTokens,
CostUsd = reported ?? estimate?.Usd ?? 0m,
CostIsKnown = reported is not null || (estimate?.IsKnown ?? false),
Status = result.Status.ToString(),
StepCount = result.StepCount,
DurationMs = (long)result.Duration.TotalMilliseconds,
OccurredAt = DateTime.Now,
BudgetPot = budgetPot ?? ""
}, 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) ───
///
/// Schreibt einen Tool-Aufruf ins Audit-Log. Best effort — ein Fehler hierbei darf den
/// Lauf nicht scheitern lassen; die eigentliche Arbeit ist bereits getan.
///
private async Task RecordAuditAsync(
string runId, AgentConfig agentConfig, string? source, string tool,
string arguments, AuditStatus status, string summary, long durationMs,
string callId = "")
{
if (_auditRepository is null)
return;
var entry = new AuditEntry
{
RunId = runId,
CallId = callId,
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
};
// Der "angefangen"-Eintrag ist der einzige, dessen Fehlschlag zählt (#554):
// Auf ihn stützt sich die Wiederaufnahme nach einem Absturz. Kommt er nicht
// durch, liefe der Aufruf ohne jede Spur — dann lieber gar nicht erst laufen
// lassen. Alle übrigen Einträge bleiben "best effort": Ein Protokollfehler soll
// einen bereits erfolgreichen Aufruf nicht nachträglich zum Scheitern bringen.
if (status == AuditStatus.Started)
{
await _auditRepository.AppendAsync(entry, CancellationToken.None);
return;
}
try
{
await _auditRepository.AppendAsync(entry, CancellationToken.None);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Audit")
.LogWarning(ex, "Audit-Eintrag konnte nicht geschrieben werden ({Tool})", tool);
}
}
///
/// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit.
///
// ─── Prompt history (#660) ───
/// Retention that applies to this agent: its own setting or the instance default.
public int TranscriptRetentionFor(AgentConfig agentConfig)
=> Transcripts.PromptHistorySettings.Resolve(agentConfig.PromptHistory?.RetentionDays, DefaultTranscriptRetention());
private Transcripts.RunTranscriptRecorder CreateRecorder(AgentConfig agentConfig)
=> TranscriptStore is not null && TranscriptRetentionFor(agentConfig) != Transcripts.PromptHistorySettings.Never
? new Transcripts.RunTranscriptRecorder(enabled: true, _clock)
: Transcripts.RunTranscriptRecorder.Disabled;
/// Best effort: a transcript that cannot be written must not fail the run.
private async Task SaveTranscriptAsync(
Transcripts.RunTranscriptRecorder recorder, string runId, AgentConfig agentConfig,
AgentRunResult result, string source, string? taskId)
{
if (!recorder.Enabled || TranscriptStore is null)
return;
try
{
var transcript = recorder.Build(runId, agentConfig.AgentId, agentConfig.Model, source, taskId, result.Status.ToString());
await TranscriptStore.SaveAsync(transcript, recorder.SystemPrompt, CancellationToken.None);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Transcripts")
.LogWarning(ex, "Transcript of run {RunId} ({AgentId}) could not be stored", runId, agentConfig.AgentId);
}
}
private async Task RecordReceiptAsync(
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId,
string? callerRunId = null, string? budgetPot = null)
{
if (_auditRepository is null)
return;
try
{
var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
// #599: derselbe Betrag wie in RunUsage. Vorher stand hier nur die Schaetzung
// nach Listenpreis ohne Cache-Rabatt - im Testbetrieb ~33 % ueber dem, was
// OpenRouter abrechnete, waehrend RunUsage auf den Cent stimmte.
var reported = result.ReportedCostUsd;
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 = reported ?? estimate?.Usd ?? 0m,
CostIsKnown = reported is not null || (estimate?.IsKnown ?? false),
DurationMs = (long)result.Duration.TotalMilliseconds,
ResultRef = Cap(result.FinalMessage ?? "", 500),
OccurredAt = DateTime.Now,
CallerRunId = callerRunId,
BudgetPot = budgetPot
}, 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] + "…";
/// Sammelt die Token-Zahlen über alle Schritte eines Runs.
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; }
///
/// Summe der vom Gateway gemeldeten Beträge (#550). Null heißt: keiner der
/// Schritte hat einen geliefert — dann bleibt es bei der Eigenrechnung.
///
/// Bewusst nur dann gesetzt, wenn wirklich etwas kam. Ein stillschweigend
/// als 0 gezählter Schritt wäre schlimmer als gar keine Angabe: Er sähe aus wie
/// ein kostenloser Lauf.
///
public decimal? ReportedCost { 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;
if (usage.Cost is { } cost)
ReportedCost = (ReportedCost ?? 0m) + cost;
}
}
///
/// 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.
///
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 BuildToolDefinitions(IReadOnlyList tools)
{
return tools.Select(t => new ToolDefinition
{
Function = new FunctionDefinition
{
Name = t.Name,
Description = t.Description,
Parameters = t.InputSchema
}
}).ToList();
}
}