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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user