Entscheidungen aus dem Testbetrieb umgesetzt (#572): #577 #579 #581 #589 #193 #205 #594 #595 #597 #598 #599 #588

- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete),
  gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten,
  Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage
- Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig,
  Selbstbearbeitung gesperrt (allowSelfEdit)
- Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf,
  Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt
- Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab,
  Token nicht mehr ins systemd-Journal
Version 0.1.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-15 11:23:10 +02:00
co-authored by Claude Opus 5
parent 8ab9d99abe
commit 0b6ea73ec1
42 changed files with 2646 additions and 512 deletions
+176 -22
View File
@@ -8,6 +8,7 @@ using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Memory;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Scheduling;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Storage;
@@ -29,6 +30,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
private readonly BudgetGuard? _budgetGuard;
private readonly ModelPricingCatalog? _pricing;
private readonly ILoggerFactory _loggerFactory;
private readonly TimeProvider _clock;
/// <summary>Tagesgrenzen der Instanz. Wird vom Host gesetzt.</summary>
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
@@ -104,8 +106,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
ModelPricingCatalog? pricing = null,
Tasks.ITaskRepository? taskRepository = null,
IAuditRepository? auditRepository = null,
Staging.StagingGate? stagingGate = null)
Staging.StagingGate? stagingGate = null,
TimeProvider? clock = null)
{
_clock = clock ?? TimeProvider.System;
_client = client;
_toolRegistry = toolRegistry;
_permissionGate = permissionGate;
@@ -195,16 +199,26 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var messages = new List<ChatMessage>();
var systemPrompt = agentConfig.FullSystemPrompt;
// #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil.
var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
messages.Add(ChatMessage.User(userMessage));
messages.Add(ChatMessage.User(MitZeit(userMessage, source)));
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();
@@ -259,24 +273,53 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// — 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);
}
if (abbruch is not null)
throw new WiederholterFehlschlagException(abbruch);
OnStepCompleted?.Invoke(agentConfig.AgentId,
$"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed");
}
else
{
finalMessage = assistantMessage.Content ?? "";
truncated = IsTruncated(choice.FinishReason);
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));
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));
continue;
}
abschluss = StatusFuer(urteil, logger, agentConfig.AgentId);
finalMessage = text;
truncated = abgeschnitten;
break;
}
}
@@ -297,7 +340,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var result = new AgentRunResult(
agentConfig.AgentId,
truncated ? AgentRunStatus.Truncated : AgentRunStatus.Completed,
truncated ? AgentRunStatus.Truncated : abschluss,
finalMessage,
loopGuard.Steps,
tally.Total,
@@ -334,6 +377,17 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
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();
@@ -459,7 +513,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (!_chatContexts.TryGetValue(agentConfig.AgentId, out messages!))
{
messages = new List<ChatMessage>();
var systemPrompt = agentConfig.FullSystemPrompt;
// #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren
// Systemprompt; die Regel kommt mit dem naechsten neuen Kontext.
var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
_chatContexts[agentConfig.AgentId] = messages;
@@ -474,13 +530,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
_ => userMessage
};
messages.Add(ChatMessage.User(routedMessage));
messages.Add(ChatMessage.User(MitZeit(routedMessage, source)));
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
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();
@@ -538,26 +603,59 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// — 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);
}
if (abbruch is not null)
throw new WiederholterFehlschlagException(abbruch);
OnStepCompleted?.Invoke(agentConfig.AgentId,
$"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)");
}
else
{
finalMessage = assistantMessage.Content ?? "";
truncated = IsTruncated(choice.FinishReason);
messages.Add(ChatMessage.Assistant(finalMessage));
AddChatEntry(agentConfig.AgentId, "assistant", finalMessage, source);
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));
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));
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;
}
}
@@ -574,7 +672,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var result = new AgentRunResult(
agentConfig.AgentId,
truncated ? AgentRunStatus.Truncated : AgentRunStatus.Completed,
truncated ? AgentRunStatus.Truncated : abschluss,
finalMessage,
loopGuard.Steps, tally.Total, sw.Elapsed)
{
@@ -597,6 +695,18 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
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();
@@ -1262,6 +1372,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
return JsonSerializer.Serialize(new { error = hinweis });
}
var input = string.IsNullOrWhiteSpace(arguments)
? default
: JsonDocument.Parse(arguments).RootElement;
var context = BuildToolContext(agentConfig, instanceId, toolName, ct, chain);
// #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)
@@ -1282,12 +1408,6 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
}
}
var input = string.IsNullOrWhiteSpace(toolCall.Function.Arguments)
? default
: JsonDocument.Parse(toolCall.Function.Arguments).RootElement;
var context = BuildToolContext(agentConfig, instanceId, toolName, ct, chain);
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
// #554: Vor der Wirkung vermerken, nicht danach. Stirbt die Anwendung
@@ -1316,7 +1436,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
}
await Audit(AuditStatus.Ok, "");
return content;
// #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)
{
@@ -1350,6 +1473,32 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
}
}
/// <summary>
/// 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.
/// </summary>
private string MitZeit(string nachricht, string? source)
=> source == ChatSource.Task
? nachricht
: ZeitAngabe.Zeile(_clock.GetUtcNow()) + "\n\n" + nachricht;
/// <summary>Der Endstatus zu einem abschliessenden Urteil (#577), mit Warnung, wenn es kein Erfolg ist.</summary>
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;
}
}
/// <summary>Ob die Argumente eines Werkzeugaufrufs vollstaendiges JSON sind (#581).</summary>
internal static bool IstVollstaendigesJson(string arguments)
{
@@ -1577,6 +1726,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
{
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,
@@ -1589,8 +1743,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens,
CachedTokens = result.CachedTokens,
CostUsd = estimate?.Usd ?? 0m,
CostIsKnown = estimate?.IsKnown ?? false,
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