Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b568a45de3 | ||
|
|
fb4b218e45 | ||
|
|
ea3844006f |
@@ -28,14 +28,45 @@ public sealed class ChatMessage
|
||||
[JsonIgnore]
|
||||
public bool CacheBreakpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Der Denkverlauf als Klartext, so wie der Anbieter ihn geliefert hat.
|
||||
/// </summary>
|
||||
public string? Reasoning { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Der strukturierte Denkverlauf, unverändert.
|
||||
///
|
||||
/// Wird bei Werkzeugnutzung in der folgenden Anfrage zurückgeschickt: Das Modell
|
||||
/// setzt seine begonnene Antwort fort, statt eine neue zu beginnen. Die Abfolge
|
||||
/// muss dabei genau der gelieferten entsprechen — umstellen oder verändern macht
|
||||
/// sie unbrauchbar, und bei signierten Formaten kann der Anbieter sie dann nicht
|
||||
/// mehr zuordnen. Deshalb wird hier rohes JSON gehalten und nichts ausgelegt.
|
||||
/// </summary>
|
||||
public JsonElement? ReasoningDetails { get; set; }
|
||||
|
||||
public static ChatMessage System(string content) => new() { Role = "system", Content = content };
|
||||
public static ChatMessage User(string content) => new() { Role = "user", Content = content };
|
||||
public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content };
|
||||
|
||||
public static ChatMessage AssistantWithToolCalls(List<ToolCall> toolCalls) => new()
|
||||
/// <summary>
|
||||
/// Baut die assistant-Nachricht für einen Zug mit Werkzeugaufrufen.
|
||||
///
|
||||
/// <paramref name="content"/> ist der Text, den das Modell neben die Aufrufe
|
||||
/// geschrieben hat („Ich sehe zuerst in der Konfiguration nach"). Er gehört in den
|
||||
/// Verlauf: Ohne ihn verliert das Modell im nächsten Schritt seinen eigenen Plan,
|
||||
/// und im Audit steht, <i>dass</i> ein Werkzeug lief, nicht <i>warum</i>.
|
||||
/// </summary>
|
||||
public static ChatMessage AssistantWithToolCalls(
|
||||
List<ToolCall> toolCalls,
|
||||
string? content = null,
|
||||
string? reasoning = null,
|
||||
JsonElement? reasoningDetails = null) => new()
|
||||
{
|
||||
Role = "assistant",
|
||||
ToolCalls = toolCalls
|
||||
Content = string.IsNullOrEmpty(content) ? null : content,
|
||||
ToolCalls = toolCalls,
|
||||
Reasoning = reasoning,
|
||||
ReasoningDetails = reasoningDetails
|
||||
};
|
||||
|
||||
public static ChatMessage ToolResponse(string toolCallId, string content) => new()
|
||||
@@ -95,6 +126,24 @@ public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
|
||||
message.ToolCallId = reader.GetString();
|
||||
break;
|
||||
|
||||
// Der Denkverlauf wird durchgereicht, nicht ausgelegt. "reasoning_content"
|
||||
// ist derselbe Inhalt unter anderem Namen; wer zuerst kommt, gewinnt.
|
||||
case "reasoning":
|
||||
case "reasoning_content":
|
||||
message.Reasoning ??= reader.TokenType == JsonTokenType.String
|
||||
? reader.GetString()
|
||||
: null;
|
||||
if (reader.TokenType is not JsonTokenType.String and not JsonTokenType.Null)
|
||||
reader.Skip();
|
||||
break;
|
||||
|
||||
case "reasoning_details":
|
||||
// Clone(), weil der Reader nach dieser Schleife nicht mehr gilt.
|
||||
message.ReasoningDetails = reader.TokenType == JsonTokenType.Null
|
||||
? null
|
||||
: JsonDocument.ParseValue(ref reader).RootElement.Clone();
|
||||
break;
|
||||
|
||||
default:
|
||||
reader.Skip();
|
||||
break;
|
||||
@@ -175,6 +224,18 @@ public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
|
||||
if (value.ToolCallId is not null)
|
||||
writer.WriteString("tool_call_id", value.ToolCallId);
|
||||
|
||||
// Unverändert zurück. Das strukturierte Feld hat Vorrang, weil nur es Signatur
|
||||
// und Reihenfolge trägt; der Klartext geht mit, wenn es keines gibt.
|
||||
if (value.ReasoningDetails is { } details)
|
||||
{
|
||||
writer.WritePropertyName("reasoning_details");
|
||||
details.WriteTo(writer);
|
||||
}
|
||||
else if (value.Reasoning is not null)
|
||||
{
|
||||
writer.WriteString("reasoning", value.Reasoning);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,98 @@ public sealed class ChatRequest
|
||||
[JsonPropertyName("stream")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool Stream { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Steuert die Anbieterauswahl des Gateways.
|
||||
///
|
||||
/// Ohne diese Angabe gilt die Voreinstellung des Gateways, und die lautet:
|
||||
/// nicht unterstützte Parameter werden vom Anbieter stillschweigend ignoriert.
|
||||
/// Für einen Agenten-Harness ist das der unangenehmste Fehlermodus überhaupt —
|
||||
/// die Fähigkeit verschwindet, ohne dass die Antwort anders aussieht.
|
||||
/// </summary>
|
||||
[JsonPropertyName("provider")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ProviderPreferences? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fordert die Abrechnungsdaten des Gateways an. Ohne das bleibt die
|
||||
/// Kostenrechnung eine Eigenrechnung aus Preisliste mal Tokenzahl.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public UsageRequest? Usage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Denktiefe. Ohne diese Angabe entsteht bei den meisten Modellen gar kein
|
||||
/// Reasoning — der Parameter schaltet es überhaupt erst ein.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoning")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ReasoningRequest? Reasoning { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbietervorgaben für das Gateway. Hier steht bewusst nur das, was diese Anwendung
|
||||
/// tatsächlich setzt — die Schnittstelle kennt deutlich mehr Felder.
|
||||
/// </summary>
|
||||
public sealed class ProviderPreferences
|
||||
{
|
||||
/// <summary>
|
||||
/// Nur an Anbieter weiterreichen, die <b>alle</b> mitgeschickten Parameter
|
||||
/// beherrschen.
|
||||
///
|
||||
/// Das ist die eigentliche Härtung: Ohne sie darf ein Anbieter
|
||||
/// <c>cache_control</c>, <c>reasoning</c> oder <c>tools</c> ignorieren, und der
|
||||
/// Lauf liefert eine gewöhnliche Textantwort, wo ein Werkzeugaufruf hingehört
|
||||
/// hätte. Mit ihr scheitert die Anfrage stattdessen sichtbar.
|
||||
/// </summary>
|
||||
[JsonPropertyName("require_parameters")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? RequireParameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ob bei Nichtverfügbarkeit auf einen anderen Anbieter ausgewichen werden darf.
|
||||
/// Null lässt die Voreinstellung des Gateways gelten.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allow_fallbacks")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? AllowFallbacks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>deny</c> schließt Anbieter aus, die Eingaben zu eigenen Zwecken speichern.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data_collection")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? DataCollection { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UsageRequest
|
||||
{
|
||||
[JsonPropertyName("include")]
|
||||
public bool Include { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Denktiefe. <see cref="Effort"/> und <see cref="MaxTokens"/> schließen einander
|
||||
/// nicht formal aus, werden aber von den Anbietern verschieden ausgelegt — deshalb
|
||||
/// setzt <see cref="Config.AgentConfig"/> immer nur eines von beiden.
|
||||
/// </summary>
|
||||
public sealed class ReasoningRequest
|
||||
{
|
||||
/// <summary>max, xhigh, high, medium, low, minimal, none.</summary>
|
||||
[JsonPropertyName("effort")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Effort { get; set; }
|
||||
|
||||
[JsonPropertyName("max_tokens")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? MaxTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Denken ja, aber nicht mitliefern. Spart Übertragung — kostet aber die
|
||||
/// Fortsetzbarkeit über Werkzeugaufrufe hinweg, weil dann nichts zurückzugeben ist.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exclude")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? Exclude { get; set; }
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ public sealed class Usage
|
||||
[JsonPropertyName("prompt_tokens_details")]
|
||||
public PromptTokensDetails? PromptTokensDetails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Der vom Gateway tatsächlich abgerechnete Betrag. Kommt nur, wenn die Anfrage
|
||||
/// <c>usage.include</c> gesetzt hat. Null heißt „nicht geliefert" — dann bleibt
|
||||
/// es bei der Eigenrechnung aus Preisliste mal Tokenzahl.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cost")]
|
||||
public decimal? Cost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anteil der Prompt-Tokens, der aus dem Cache gelesen wurde. Nur damit lässt sich
|
||||
/// belegen, ob das Prompt-Caching tatsächlich greift — ein still wirkungsloser
|
||||
|
||||
@@ -3,6 +3,16 @@ namespace ClawdDotNet.Core.Audit;
|
||||
/// <summary>Ausgang eines Tool-Aufrufs, von der Engine festgestellt.</summary>
|
||||
public enum AuditStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Der Aufruf beginnt jetzt (#554). Der einzige Eintrag, der <b>vor</b> der Wirkung
|
||||
/// geschrieben wird — und der einzige, der deshalb einen harten Absturz überlebt.
|
||||
///
|
||||
/// <para>Bleibt er ohne Abschluss, lief der Aufruf, als die Anwendung starb. Was er
|
||||
/// dabei bewirkt hat, sagt er nicht: Der Absturz kann vor oder nach der Wirkung
|
||||
/// liegen. Er grenzt die Frage ein, er beantwortet sie nicht.</para>
|
||||
/// </summary>
|
||||
Started,
|
||||
|
||||
/// <summary>Tool lief und lieferte ein Ergebnis.</summary>
|
||||
Ok,
|
||||
|
||||
@@ -16,7 +26,18 @@ public enum AuditStatus
|
||||
NotFound,
|
||||
|
||||
/// <summary>Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt.</summary>
|
||||
Staged
|
||||
Staged,
|
||||
|
||||
/// <summary>
|
||||
/// Der Aufruf wurde unterbrochen, während er lief (#554) — Benutzerabbruch,
|
||||
/// Zeitüberschreitung oder geordnetes Ende der Instanz.
|
||||
///
|
||||
/// <para>Wie beim Absturz ist die Wirkung ungewiss. Der Unterschied ist, dass wir
|
||||
/// hier <b>wissen</b>, dass unterbrochen wurde. Ohne diesen Eintrag bliebe der
|
||||
/// <see cref="Started"/>-Eintrag offen, und jeder gewöhnliche Abbruch sähe beim
|
||||
/// nächsten Start aus wie ein Absturz — bis niemand mehr hinsieht.</para>
|
||||
/// </summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -34,6 +55,15 @@ public sealed record AuditEntry
|
||||
/// <summary>Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs.</summary>
|
||||
public string RunId { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Id des einzelnen Werkzeugaufrufs (die <c>tool_call_id</c> des Modells). Paart
|
||||
/// den <see cref="AuditStatus.Started"/>-Eintrag mit seinem Abschluss (#554).
|
||||
///
|
||||
/// <para>Leer bei Einträgen aus der Zeit vor #554 und bei solchen, die keinem
|
||||
/// einzelnen Aufruf zuzuordnen sind.</para>
|
||||
/// </summary>
|
||||
public string CallId { get; init; } = "";
|
||||
|
||||
public string AgentId { get; init; } = "";
|
||||
|
||||
/// <summary>Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source).</summary>
|
||||
@@ -93,6 +123,37 @@ public sealed record RunReceipt
|
||||
public DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kennzahlen eines Werkzeugs bei einem Agenten (#556). Aus dem Audit-Log gerechnet,
|
||||
/// ohne eine einzige zusätzliche Messung.
|
||||
/// </summary>
|
||||
public sealed record ToolUsageStat
|
||||
{
|
||||
public string AgentId { get; init; } = "";
|
||||
public string Tool { get; init; } = "";
|
||||
|
||||
/// <summary>Abgeschlossene Aufrufe — <see cref="AuditStatus.Started"/> zählt nicht mit.</summary>
|
||||
public int Calls { get; init; }
|
||||
|
||||
public int Failed { get; init; }
|
||||
public int Denied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aufrufe, die begannen und nie endeten. Nicht dasselbe wie gescheitert: Hier fehlt
|
||||
/// die Auskunft, statt dass sie negativ wäre.
|
||||
/// </summary>
|
||||
public int Unfinished { get; init; }
|
||||
|
||||
public long TotalDurationMs { get; init; }
|
||||
public long MaxDurationMs { get; init; }
|
||||
|
||||
/// <summary>Mittlere Dauer. 0, wenn nichts abgeschlossen wurde.</summary>
|
||||
public double AverageDurationMs => Calls == 0 ? 0 : (double)TotalDurationMs / Calls;
|
||||
|
||||
/// <summary>Anteil der gescheiterten an den abgeschlossenen Aufrufen, 0 bis 1.</summary>
|
||||
public double FailureRate => Calls == 0 ? 0 : (double)Failed / Calls;
|
||||
}
|
||||
|
||||
/// <summary>Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit
|
||||
/// <c>ChatSource</c>; <see cref="Unknown"/> steht für „nicht bekannt", nicht für geraten.</summary>
|
||||
public static class AuditSource
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Wie mit einem Aufruf zu verfahren ist, der beim Absturz lief.
|
||||
/// </summary>
|
||||
public enum RecoveryVerdict
|
||||
{
|
||||
/// <summary>
|
||||
/// Das Werkzeug gibt sich als wiederholbar aus. Ein zweiter, gleicher Aufruf
|
||||
/// führt zum selben Zustand — ob der erste wirkte, muss deshalb niemand wissen.
|
||||
/// </summary>
|
||||
Repeatable,
|
||||
|
||||
/// <summary>
|
||||
/// Die Wirkung ist ungewiss und eine Wiederholung nicht unbedenklich. Gehört einem
|
||||
/// Menschen vorgelegt, nicht automatisch entschieden.
|
||||
/// </summary>
|
||||
NeedsReview,
|
||||
|
||||
/// <summary>
|
||||
/// Das Werkzeug gibt es nicht mehr — umbenannt, entfernt, oder dem Agenten
|
||||
/// entzogen. Wiederholen ginge gar nicht; hier bleibt nur der Hinweis.
|
||||
/// </summary>
|
||||
ToolUnknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Aufruf, der beim Absturz lief, mit seiner Einstufung — und, falls das Werkzeug
|
||||
/// nachsehen konnte, mit dem Befund der Nachschau (#555).
|
||||
/// </summary>
|
||||
public sealed record RecoveryCase(
|
||||
AuditEntry Entry,
|
||||
RecoveryVerdict Verdict,
|
||||
string Reason,
|
||||
ProbeFinding? Probe = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Ob dieser Fall ohne menschliche Entscheidung erledigt ist.
|
||||
///
|
||||
/// <para>Zwei Wege führen dahin: Das Werkzeug ist wiederholbar, oder die Nachschau
|
||||
/// hat die Ungewissheit aufgelöst. Ein <see cref="ProbeOutcome.Complete"/> heißt,
|
||||
/// die Wirkung ist eingetreten — nichts zu tun. Ein <see cref="ProbeOutcome.NotStarted"/>
|
||||
/// heißt, sie ist es nicht — der Aufruf kann gefahrlos wiederholt werden.</para>
|
||||
///
|
||||
/// <para><see cref="ProbeOutcome.Partial"/> gehört ausdrücklich <b>nicht</b> dazu.
|
||||
/// Ein halb angehängter Text ist genau der Fall, den niemand automatisch
|
||||
/// geraderücken sollte.</para>
|
||||
/// </summary>
|
||||
public bool Erledigt =>
|
||||
Verdict == RecoveryVerdict.Repeatable ||
|
||||
Probe?.Outcome is ProbeOutcome.Complete or ProbeOutcome.NotStarted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sichtet nach dem Start, was beim letzten Mal offen geblieben ist (#554).
|
||||
///
|
||||
/// <para><b>Was dieser Dienst kann und was nicht:</b> Er findet Aufrufe, die begonnen
|
||||
/// und nie geendet haben, und stuft sie ein. Er kann <i>nicht</i> sagen, ob ihre Wirkung
|
||||
/// eingetreten ist — der Absturz kann vor oder nach dem Versenden einer Mail liegen, und
|
||||
/// im Protokoll steht beides Mal dasselbe. Er verkleinert die Frage von „was ist in zehn
|
||||
/// Minuten passiert?" auf „diese zwei Aufrufe waren in der Luft". Mehr nicht, und das
|
||||
/// absichtlich: Ein Dienst, der hier eine Gewissheit vortäuschte, führte zu doppelt
|
||||
/// versendeten Mails.</para>
|
||||
///
|
||||
/// <para>Er führt deshalb auch nichts aus. Er sichtet und berichtet; was geschieht,
|
||||
/// entscheidet der Aufrufer.</para>
|
||||
/// </summary>
|
||||
public sealed class CrashRecovery
|
||||
{
|
||||
private readonly IAuditRepository _audit;
|
||||
private readonly ToolRegistry _tools;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Baut den Kontext, den eine Nachschau braucht — Arbeitsverzeichnisse, Einstellungen.
|
||||
/// Null heißt: keine Nachschau, nur Einstufung. Die Engine kennt diese Angaben, der
|
||||
/// Wiederaufnahmedienst nicht; deshalb werden sie hereingereicht statt nachgebaut.
|
||||
/// </summary>
|
||||
private readonly Func<string, string, AgentToolContext?>? _kontextFuer;
|
||||
|
||||
public CrashRecovery(
|
||||
IAuditRepository audit,
|
||||
ToolRegistry tools,
|
||||
ILoggerFactory loggerFactory,
|
||||
Func<string, string, AgentToolContext?>? kontextFuer = null)
|
||||
{
|
||||
_audit = audit;
|
||||
_tools = tools;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Audit.CrashRecovery");
|
||||
_kontextFuer = kontextFuer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sichtet die offenen Aufrufe. Leere Liste heißt: Das letzte Ende war geordnet.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RecoveryCase>> SichtenAsync(CancellationToken ct)
|
||||
{
|
||||
var offen = await _audit.ListOpenCallsAsync(ct);
|
||||
|
||||
if (offen.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Keine offenen Werkzeugaufrufe — das letzte Ende war geordnet.");
|
||||
return [];
|
||||
}
|
||||
|
||||
var faelle = new List<RecoveryCase>();
|
||||
foreach (var eintrag in offen)
|
||||
faelle.Add(await NachsehenAsync(Einstufen(eintrag), ct));
|
||||
|
||||
var zurPruefung = faelle.Count(f => !f.Erledigt);
|
||||
|
||||
_logger.LogWarning(
|
||||
"{Gesamt} Werkzeugaufruf(e) liefen beim letzten Ende und wurden nie abgeschlossen. " +
|
||||
"{Erledigt} davon sind geklärt, {Pruefung} brauchen eine Entscheidung.",
|
||||
faelle.Count, faelle.Count - zurPruefung, zurPruefung);
|
||||
|
||||
foreach (var fall in faelle)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Offen: {Tool} von {Agent} (Lauf {RunId}, begonnen {Zeit:yyyy-MM-dd HH:mm:ss}) — " +
|
||||
"{Urteil}: {Grund}{Nachschau}",
|
||||
fall.Entry.Tool, fall.Entry.AgentId, fall.Entry.RunId,
|
||||
fall.Entry.OccurredAt, fall.Verdict, fall.Reason,
|
||||
fall.Probe is { } p ? $" Nachschau: {p.Outcome} — {p.Evidence}" : "");
|
||||
}
|
||||
|
||||
return faelle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt das Werkzeug, was von dem Aufruf übrig ist — sofern es das beantworten kann
|
||||
/// (#555). Ein Werkzeug ohne Nachschau ist der Normalfall, kein Mangel.
|
||||
/// </summary>
|
||||
private async Task<RecoveryCase> NachsehenAsync(RecoveryCase fall, CancellationToken ct)
|
||||
{
|
||||
if (_tools.Get(fall.Entry.Tool) is not IToolStateProbe probe || _kontextFuer is null)
|
||||
return fall;
|
||||
|
||||
var kontext = _kontextFuer(fall.Entry.AgentId, fall.Entry.Tool);
|
||||
if (kontext is null)
|
||||
return fall;
|
||||
|
||||
try
|
||||
{
|
||||
// Die Argumente werden gekappt gespeichert. Ein abgeschnittener Eintrag ist
|
||||
// kein gültiges JSON — dann gibt es nichts nachzusehen, und das ist eine
|
||||
// Aussage über unser Protokoll, nicht über die Welt.
|
||||
using var argumente = JsonDocument.Parse(fall.Entry.Arguments);
|
||||
|
||||
var befund = await probe.ProbeAsync(argumente.RootElement, kontext, ct);
|
||||
return fall with { Probe = befund };
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return fall with
|
||||
{
|
||||
Probe = ProbeFinding.Unknown(
|
||||
"Die protokollierten Argumente sind unvollständig (gekappt) — " +
|
||||
"der Aufruf lässt sich nicht nachstellen.")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Nachschau für {Tool} scheiterte.", fall.Entry.Tool);
|
||||
return fall with { Probe = ProbeFinding.Unknown($"Nachschau scheiterte: {ex.Message}") };
|
||||
}
|
||||
}
|
||||
|
||||
private RecoveryCase Einstufen(AuditEntry entry)
|
||||
{
|
||||
var tool = _tools.Get(entry.Tool);
|
||||
|
||||
if (tool is null)
|
||||
{
|
||||
return new RecoveryCase(entry, RecoveryVerdict.ToolUnknown,
|
||||
$"Werkzeug '{entry.Tool}' ist nicht mehr registriert.");
|
||||
}
|
||||
|
||||
if (tool.IsRepeatable)
|
||||
{
|
||||
return new RecoveryCase(entry, RecoveryVerdict.Repeatable,
|
||||
"Das Werkzeug gibt sich als wiederholbar aus — ein zweiter Aufruf führt zum selben Zustand.");
|
||||
}
|
||||
|
||||
return new RecoveryCase(entry, RecoveryVerdict.NeedsReview,
|
||||
"Wirkung ungewiss und Wiederholung nicht als unbedenklich gekennzeichnet.");
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,25 @@ public interface IAuditRepository
|
||||
|
||||
/// <summary>Zahl der Log-Einträge insgesamt.</summary>
|
||||
Task<int> CountAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Aufrufe, die begonnen haben und nie abgeschlossen wurden (#554) — also
|
||||
/// <see cref="AuditStatus.Started"/> ohne späteren Eintrag mit derselben
|
||||
/// <see cref="AuditEntry.CallId"/>.
|
||||
///
|
||||
/// <para>Nach einem geordneten Ende ist die Liste leer. Ist sie es nicht, ist die
|
||||
/// Anwendung gestorben, während diese Aufrufe liefen. Was sie bewirkt haben, sagt
|
||||
/// die Liste nicht — das ist die Frage, die danach zu klären ist.</para>
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Die jüngsten Aufrufe eines Agenten, optional ab einem Zeitpunkt (#556).</summary>
|
||||
Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
|
||||
string agentId, DateTime? seit, int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Kennzahlen je Agent und Werkzeug (#556): wie oft, wie oft vergeblich, wie lange.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
|
||||
string? agentId, DateTime? seit, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ public sealed class SqliteAuditRepository : IAuditRepository
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO AuditLog
|
||||
(RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
|
||||
(RunId, CallId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
|
||||
VALUES
|
||||
(@runId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
|
||||
(@runId, @callId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@runId", entry.RunId);
|
||||
cmd.Parameters.AddWithValue("@callId", entry.CallId);
|
||||
cmd.Parameters.AddWithValue("@agentId", entry.AgentId);
|
||||
cmd.Parameters.AddWithValue("@model", entry.Model);
|
||||
cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source));
|
||||
@@ -120,10 +121,113 @@ public sealed class SqliteAuditRepository : IAuditRepository
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offene Aufrufe (#554): ein <c>Started</c>, zu dem kein späterer Eintrag mit
|
||||
/// derselben CallId existiert.
|
||||
///
|
||||
/// <para>Einträge ohne CallId bleiben außen vor — sie stammen aus der Zeit vor #554
|
||||
/// und ließen sich nicht paaren. Sie alle als offen zu melden, wäre schlimmer als
|
||||
/// sie zu übergehen: Der erste Start nach der Aktualisierung legte sonst das ganze
|
||||
/// Altprotokoll zur Prüfung vor.</para>
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"""
|
||||
SELECT {AuditColumns} FROM AuditLog a
|
||||
WHERE a.Status = 'Started'
|
||||
AND a.CallId <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM AuditLog b
|
||||
WHERE b.CallId = a.CallId
|
||||
AND b.Id > a.Id
|
||||
AND b.Status <> 'Started'
|
||||
)
|
||||
ORDER BY a.Id
|
||||
""";
|
||||
return await ReadEntriesAsync(cmd, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
|
||||
string agentId, DateTime? seit, int limit, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"""
|
||||
SELECT {AuditColumns} FROM AuditLog
|
||||
WHERE AgentId = @agentId
|
||||
AND (@seit IS NULL OR OccurredAt >= @seit)
|
||||
ORDER BY Id DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@agentId", agentId);
|
||||
cmd.Parameters.AddWithValue("@seit", seit is { } s ? Format(s) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@limit", limit);
|
||||
return await ReadEntriesAsync(cmd, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kennzahlen je Agent und Werkzeug (#556).
|
||||
///
|
||||
/// <para>Die Dauer wird nur über abgeschlossene Aufrufe gemittelt. Ein
|
||||
/// <c>Started</c>-Eintrag trägt die Dauer 0, weil zum Zeitpunkt des Schreibens noch
|
||||
/// nichts gelaufen war — würde er mitgezählt, sähe jedes Werkzeug doppelt so schnell
|
||||
/// aus, wie es ist.</para>
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
|
||||
string? agentId, DateTime? seit, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT AgentId, Tool,
|
||||
SUM(CASE WHEN Status <> 'Started' THEN 1 ELSE 0 END) AS Calls,
|
||||
SUM(CASE WHEN Status = 'Error' THEN 1 ELSE 0 END) AS Failed,
|
||||
SUM(CASE WHEN Status = 'Denied' THEN 1 ELSE 0 END) AS Denied,
|
||||
SUM(CASE WHEN Status = 'Started' THEN 1 ELSE 0 END) AS Begonnen,
|
||||
SUM(CASE WHEN Status <> 'Started' THEN DurationMs ELSE 0 END) AS GesamtMs,
|
||||
MAX(DurationMs) AS MaxMs
|
||||
FROM AuditLog
|
||||
WHERE (@agentId IS NULL OR AgentId = @agentId)
|
||||
AND (@seit IS NULL OR OccurredAt >= @seit)
|
||||
GROUP BY AgentId, Tool
|
||||
ORDER BY Calls DESC
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@agentId", (object?)agentId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@seit", seit is { } s ? Format(s) : DBNull.Value);
|
||||
|
||||
var results = new List<ToolUsageStat>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var abgeschlossen = reader.GetInt32(2);
|
||||
var begonnen = reader.GetInt32(5);
|
||||
|
||||
results.Add(new ToolUsageStat
|
||||
{
|
||||
AgentId = reader.GetString(0),
|
||||
Tool = reader.GetString(1),
|
||||
Calls = abgeschlossen,
|
||||
Failed = reader.GetInt32(3),
|
||||
Denied = reader.GetInt32(4),
|
||||
|
||||
// Begonnene minus abgeschlossene: Was übrig bleibt, hat nie geendet.
|
||||
// Nie negativ — ein Abschluss ohne Start kann es nicht geben, aber
|
||||
// Alteinträge ohne Started-Zeile gibt es sehr wohl.
|
||||
Unfinished = Math.Max(0, begonnen - abgeschlossen),
|
||||
|
||||
TotalDurationMs = reader.GetInt64(6),
|
||||
MaxDurationMs = reader.GetInt64(7)
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string AuditColumns =
|
||||
"Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt";
|
||||
"Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt, CallId";
|
||||
|
||||
private const string ReceiptColumns =
|
||||
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
|
||||
@@ -150,7 +254,8 @@ public sealed class SqliteAuditRepository : IAuditRepository
|
||||
Status = Enum.TryParse<AuditStatus>(r.GetString(7), out var s) ? s : AuditStatus.Ok,
|
||||
Summary = r.GetString(8),
|
||||
DurationMs = r.GetInt64(9),
|
||||
OccurredAt = Parse(r.GetString(10))
|
||||
OccurredAt = Parse(r.GetString(10)),
|
||||
CallId = r.GetString(11)
|
||||
};
|
||||
|
||||
private static RunReceipt ReadReceipt(SqliteDataReader r) => new()
|
||||
|
||||
@@ -107,8 +107,60 @@ public sealed class AgentConfig
|
||||
[JsonPropertyName("maxToolResultChars")]
|
||||
public int MaxToolResultChars { get; set; } = 16_000;
|
||||
|
||||
/// <summary>
|
||||
/// Wie viele als nebenläufig gekennzeichnete Werkzeuge eines Zuges gleichzeitig
|
||||
/// laufen dürfen. 1 heißt: nacheinander, wie bisher.
|
||||
///
|
||||
/// Die Grenze ist keine Leistungsstellschraube, sondern eine Rücksichtnahme —
|
||||
/// zwanzig gleichzeitige Abrufe gegen dieselbe Gegenstelle sind aus deren Sicht
|
||||
/// nicht von einem Angriff zu unterscheiden.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxParallelToolCalls")]
|
||||
public int MaxParallelToolCalls { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Denktiefe: "off" (Vorgabe), "minimal", "low", "medium", "high", "xhigh", "max"
|
||||
/// — oder eine Tokenzahl als Zeichenkette, etwa "4096".
|
||||
///
|
||||
/// Vorgabe ist "off", damit sich für bestehende Agenten nichts ändert: Denk-Tokens
|
||||
/// zählen als Ausgabe-Tokens und damit gegen <see cref="LoopGuardConfig.MaxResponseTokens"/>
|
||||
/// wie gegen das Budget.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoningEffort")]
|
||||
public string ReasoningEffort { get; set; } = "off";
|
||||
|
||||
/// <summary>
|
||||
/// Ob das Gateway nur an Anbieter weiterreichen darf, die alle mitgeschickten
|
||||
/// Parameter beherrschen.
|
||||
///
|
||||
/// Vorgabe <c>true</c>. Ohne das darf ein Anbieter <c>cache_control</c>,
|
||||
/// <c>reasoning</c> oder sogar <c>tools</c> stillschweigend übergehen — der Lauf
|
||||
/// liefert dann eine gewöhnliche Textantwort, wo ein Werkzeugaufruf hingehört
|
||||
/// hätte, und nichts weist darauf hin. Lieber eine sichtbare Absage als ein
|
||||
/// unbemerkt entwerteter Lauf.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requireProviderParameters")]
|
||||
public bool RequireProviderParameters { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("budget")]
|
||||
public AgentBudget Budget { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Übersetzt <see cref="ReasoningEffort"/> in das Anfragefeld. Null heißt:
|
||||
/// nichts mitschicken, das Modell denkt wie voreingestellt.
|
||||
/// </summary>
|
||||
public Api.Models.ReasoningRequest? BuildReasoningRequest()
|
||||
{
|
||||
var value = (ReasoningEffort ?? "").Trim();
|
||||
|
||||
if (value.Length == 0 || value.Equals("off", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
if (int.TryParse(value, out var tokens))
|
||||
return tokens > 0 ? new Api.Models.ReasoningRequest { MaxTokens = tokens } : null;
|
||||
|
||||
return new Api.Models.ReasoningRequest { Effort = value.ToLowerInvariant() };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -197,6 +197,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
messages.Add(ChatMessage.User(userMessage));
|
||||
|
||||
string? finalMessage = null;
|
||||
var truncated = false;
|
||||
var tally = new TokenTally();
|
||||
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
||||
|
||||
@@ -216,16 +217,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
if (cachingEnabled)
|
||||
PromptCache.ApplyBreakpoints(messages);
|
||||
|
||||
var request = new ChatRequest
|
||||
{
|
||||
Model = agentConfig.Model,
|
||||
Messages = messages,
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
|
||||
// B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
|
||||
MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
|
||||
? agentConfig.LoopGuard.MaxResponseTokens
|
||||
: null
|
||||
};
|
||||
var request = BuildRequest(agentConfig, messages, toolDefinitions);
|
||||
|
||||
var estimated = _compactor.EstimateFor(agentConfig.Model, messages);
|
||||
|
||||
@@ -249,14 +241,29 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
if (assistantMessage.ToolCalls is { Count: > 0 })
|
||||
{
|
||||
messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls));
|
||||
// #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));
|
||||
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
// #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];
|
||||
try
|
||||
{
|
||||
var toolResult = await ExecuteToolCallAsync(
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source, chain);
|
||||
|
||||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||||
await ExecuteToolCallsAsync(
|
||||
assistantMessage.ToolCalls, toolResults, agentConfig, instanceId, tools,
|
||||
logger, ct, runId, source, chain);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
|
||||
}
|
||||
|
||||
OnStepCompleted?.Invoke(agentConfig.AgentId,
|
||||
@@ -265,18 +272,28 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
else
|
||||
{
|
||||
finalMessage = assistantMessage.Content ?? "";
|
||||
truncated = IsTruncated(choice.FinishReason);
|
||||
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,
|
||||
AgentRunStatus.Completed,
|
||||
truncated ? AgentRunStatus.Truncated : AgentRunStatus.Completed,
|
||||
finalMessage,
|
||||
loopGuard.Steps,
|
||||
tally.Total,
|
||||
@@ -284,7 +301,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
{
|
||||
PromptTokens = tally.Prompt,
|
||||
CompletionTokens = tally.Completion,
|
||||
CachedTokens = tally.Cached
|
||||
CachedTokens = tally.Cached,
|
||||
ReportedCostUsd = tally.ReportedCost
|
||||
};
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
@@ -453,6 +471,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
|
||||
|
||||
string? finalMessage = null;
|
||||
var truncated = false;
|
||||
var tally = new TokenTally();
|
||||
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
||||
|
||||
@@ -474,16 +493,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
if (cachingEnabled)
|
||||
PromptCache.ApplyBreakpoints(messages);
|
||||
|
||||
var request = new ChatRequest
|
||||
{
|
||||
Model = agentConfig.Model,
|
||||
Messages = messages,
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
|
||||
// B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
|
||||
MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
|
||||
? agentConfig.LoopGuard.MaxResponseTokens
|
||||
: null
|
||||
};
|
||||
var 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
|
||||
@@ -510,13 +520,27 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
if (assistantMessage.ToolCalls is { Count: > 0 })
|
||||
{
|
||||
messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls));
|
||||
// #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));
|
||||
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
// #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];
|
||||
try
|
||||
{
|
||||
var toolResult = await ExecuteToolCallAsync(
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source, chain);
|
||||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||||
await ExecuteToolCallsAsync(
|
||||
assistantMessage.ToolCalls, toolResults, agentConfig, instanceId, tools,
|
||||
logger, ct, runId, source, chain);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
|
||||
}
|
||||
|
||||
OnStepCompleted?.Invoke(agentConfig.AgentId,
|
||||
@@ -525,6 +549,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
else
|
||||
{
|
||||
finalMessage = assistantMessage.Content ?? "";
|
||||
truncated = IsTruncated(choice.FinishReason);
|
||||
messages.Add(ChatMessage.Assistant(finalMessage));
|
||||
AddChatEntry(agentConfig.AgentId, "assistant", finalMessage, source);
|
||||
break;
|
||||
@@ -532,13 +557,25 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
}
|
||||
|
||||
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, AgentRunStatus.Completed, finalMessage,
|
||||
agentConfig.AgentId,
|
||||
truncated ? AgentRunStatus.Truncated : AgentRunStatus.Completed,
|
||||
finalMessage,
|
||||
loopGuard.Steps, tally.Total, sw.Elapsed)
|
||||
{
|
||||
PromptTokens = tally.Prompt,
|
||||
CompletionTokens = tally.Completion,
|
||||
CachedTokens = tally.Cached
|
||||
CachedTokens = tally.Cached,
|
||||
ReportedCostUsd = tally.ReportedCost
|
||||
};
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
@@ -1025,6 +1062,155 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ob das Modell abgebrochen wurde, statt fertig zu werden (#548).
|
||||
///
|
||||
/// <para>Der Grund steht in <c>finish_reason</c>. Anbieter schreiben ihn verschieden:
|
||||
/// <c>length</c> ist die verbreitete Form, <c>max_tokens</c> kommt bei einigen
|
||||
/// vor. Beides meint dasselbe — die Ausgabegrenze war erreicht, bevor der Gedanke
|
||||
/// zu Ende war.</para>
|
||||
///
|
||||
/// <para>Fehlt das Feld ganz, wird nicht von Abbruch ausgegangen. Ein Anbieter, der
|
||||
/// nichts meldet, soll keine Warnung auslösen, die es nicht gibt.</para>
|
||||
/// </summary>
|
||||
internal static bool IsTruncated(string? finishReason) =>
|
||||
finishReason is not null &&
|
||||
(finishReason.Equals("length", StringComparison.OrdinalIgnoreCase) ||
|
||||
finishReason.Equals("max_tokens", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static ChatRequest BuildRequest(
|
||||
AgentConfig agentConfig,
|
||||
List<ChatMessage> messages,
|
||||
List<ToolDefinition> 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()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Führt die Werkzeugaufrufe eines Zuges aus und liefert die Ergebnisse
|
||||
/// <b>in der Reihenfolge der Aufrufe</b> 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.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal const string AbgebrochenerAufruf =
|
||||
"""{"error":"Aufruf abgebrochen, bevor ein Ergebnis vorlag."}""";
|
||||
|
||||
private async Task ExecuteToolCallsAsync(
|
||||
IReadOnlyList<ToolCall> toolCalls,
|
||||
string?[] results,
|
||||
AgentConfig agentConfig,
|
||||
string instanceId,
|
||||
IReadOnlyList<IAgentTool> 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<int>();
|
||||
var serial = new List<int>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hängt für <b>jeden</b> Aufruf eine Antwort an — auch für den, der nie zu Ende kam
|
||||
/// (#552).
|
||||
///
|
||||
/// <para>Wird aus einem <c>finally</c> 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 <c>PersistChatState</c> wanderte sie zuverlässig auf die Platte. Der Agent
|
||||
/// wäre danach auch nach einem Neustart nicht mehr ansprechbar.</para>
|
||||
/// </summary>
|
||||
private static void AppendToolResponses(
|
||||
List<ChatMessage> messages,
|
||||
IReadOnlyList<ToolCall> toolCalls,
|
||||
string?[] results)
|
||||
{
|
||||
for (var i = 0; i < toolCalls.Count; i++)
|
||||
messages.Add(ChatMessage.ToolResponse(toolCalls[i].Id, results[i] ?? AbgebrochenerAufruf));
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteToolCallAsync(
|
||||
ToolCall toolCall,
|
||||
AgentConfig agentConfig,
|
||||
@@ -1044,7 +1230,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
// 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);
|
||||
=> RecordAuditAsync(runId, agentConfig, source, toolName, arguments, status, summary,
|
||||
sw.ElapsedMilliseconds, toolCall.Id);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -1085,6 +1272,13 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
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);
|
||||
@@ -1115,8 +1309,19 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
|
||||
// Schritt weiter und der Abbruch greift erst verzögert. Auch kein Audit —
|
||||
// der Aufruf kam nicht zum Abschluss.
|
||||
// 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)
|
||||
@@ -1234,6 +1439,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
{
|
||||
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,
|
||||
@@ -1241,8 +1452,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),
|
||||
Status = result.Status.ToString(),
|
||||
StepCount = result.StepCount,
|
||||
DurationMs = (long)result.Duration.TotalMilliseconds,
|
||||
@@ -1265,26 +1476,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
/// </summary>
|
||||
private async Task RecordAuditAsync(
|
||||
string runId, AgentConfig agentConfig, string? source, string tool,
|
||||
string arguments, AuditStatus status, string summary, long durationMs)
|
||||
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(new AuditEntry
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentConfig.AgentId,
|
||||
Model = agentConfig.Model,
|
||||
Source = AuditSource.Normalize(source),
|
||||
Tool = tool,
|
||||
Arguments = Cap(arguments, 4_000),
|
||||
Status = status,
|
||||
Summary = Cap(summary, 500),
|
||||
DurationMs = durationMs,
|
||||
OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
await _auditRepository.AppendAsync(entry, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1343,6 +1569,16 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
public int Completion { get; private set; }
|
||||
public int Cached { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Summe der vom Gateway gemeldeten Beträge (#550). Null heißt: keiner der
|
||||
/// Schritte hat einen geliefert — dann bleibt es bei der Eigenrechnung.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public decimal? ReportedCost { get; private set; }
|
||||
|
||||
// Voll qualifiziert: "Usage" ist auch ein Namespace (ClawdDotNet.Core.Accounting).
|
||||
public void Add(Api.Models.Usage usage)
|
||||
{
|
||||
@@ -1350,6 +1586,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
Prompt += usage.PromptTokens;
|
||||
Completion += usage.CompletionTokens;
|
||||
Cached += usage.CachedTokens;
|
||||
|
||||
if (usage.Cost is { } cost)
|
||||
ReportedCost = (ReportedCost ?? 0m) + cost;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,15 @@ public sealed record AgentRunResult(
|
||||
/// in <see cref="PromptTokens"/> enthalten, kosten aber nur einen Bruchteil.
|
||||
/// </summary>
|
||||
public int CachedTokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Der vom Gateway gemeldete Betrag über alle Schritte (#550), falls geliefert.
|
||||
///
|
||||
/// <para>Er ist der Eigenrechnung vorzuziehen, weil er das abbildet, was tatsächlich
|
||||
/// abgerechnet wurde — einschließlich dessen, was eine Preisliste nicht kennt:
|
||||
/// Cache-Schreibaufschläge, abweichende Preise je Anbieter, Rabatte.</para>
|
||||
/// </summary>
|
||||
public decimal? ReportedCostUsd { get; init; }
|
||||
}
|
||||
|
||||
public enum AgentRunStatus
|
||||
@@ -47,5 +56,16 @@ public enum AgentRunStatus
|
||||
/// Andersherum ginge die Zahl der offenen Läufe nie auf null, weil ständig neue
|
||||
/// dazukämen — die Instanz käme nie zum Ende.</para>
|
||||
/// </summary>
|
||||
ShuttingDown
|
||||
ShuttingDown,
|
||||
|
||||
/// <summary>
|
||||
/// Das Modell stieß an die Ausgabegrenze, bevor es fertig war (#548).
|
||||
///
|
||||
/// <para>Der Lauf ist damit nicht gescheitert — es liegt ein Teilergebnis vor, und
|
||||
/// das steht in <see cref="AgentRunResult.FinalMessage"/>. Er ist aber auch nicht
|
||||
/// <see cref="Completed"/>: Was dort steht, ist ein abgeschnittener Gedanke, kein
|
||||
/// Schluss. Der Unterschied muss sichtbar sein, weil ein geplanter Lauf im
|
||||
/// Hintergrund sonst ein halbes Ergebnis weiterreicht, als wäre es ein ganzes.</para>
|
||||
/// </summary>
|
||||
Truncated
|
||||
}
|
||||
|
||||
@@ -202,7 +202,10 @@ public sealed class SqliteStorage
|
||||
Status TEXT NOT NULL,
|
||||
Summary TEXT NOT NULL DEFAULT '',
|
||||
DurationMs INTEGER NOT NULL DEFAULT 0,
|
||||
OccurredAt TEXT NOT NULL
|
||||
OccurredAt TEXT NOT NULL,
|
||||
-- Paart den "angefangen"-Eintrag mit seinem Abschluss (#554).
|
||||
-- Wird fuer bestehende Datenbanken in Nachruesten() ergaenzt.
|
||||
CallId TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
|
||||
@@ -261,5 +264,55 @@ public sealed class SqliteStorage
|
||||
""";
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
Nachruesten(connection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spalten, die nach der ersten Fassung einer Tabelle dazugekommen sind.
|
||||
///
|
||||
/// <para><c>CREATE TABLE IF NOT EXISTS</c> allein genügt dafür nicht: Bei einer
|
||||
/// bestehenden Datenbank tut die Anweisung gar nichts, die neue Spalte fehlt, und
|
||||
/// erst das nächste <c>INSERT</c>, das sie benennt, scheitert — im Betrieb, beim
|
||||
/// Anwender, nicht beim Entwickler mit der frischen Datei.</para>
|
||||
/// </summary>
|
||||
private static void Nachruesten(SqliteConnection connection)
|
||||
{
|
||||
// #554: Paart den "angefangen"-Eintrag mit seinem Abschluss.
|
||||
SpalteErgaenzen(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
|
||||
using var index = connection.CreateCommand();
|
||||
index.CommandText = """
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Call
|
||||
ON AuditLog (CallId, Id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Agent
|
||||
ON AuditLog (AgentId, Id DESC);
|
||||
""";
|
||||
index.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ergänzt eine Spalte, falls sie fehlt. Ist sie da, geschieht nichts.
|
||||
///
|
||||
/// <para>SQLite kennt kein <c>ADD COLUMN IF NOT EXISTS</c>, deshalb wird erst
|
||||
/// nachgesehen. Die Vorgabe ist Pflicht: Eine <c>NOT NULL</c>-Spalte ohne
|
||||
/// Vorgabewert lässt sich einer gefüllten Tabelle nicht hinzufügen.</para>
|
||||
/// </summary>
|
||||
private static void SpalteErgaenzen(SqliteConnection connection, string tabelle, string spalte, string definition)
|
||||
{
|
||||
using (var pruefen = connection.CreateCommand())
|
||||
{
|
||||
pruefen.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{tabelle}') WHERE name = @spalte";
|
||||
pruefen.Parameters.AddWithValue("@spalte", spalte);
|
||||
|
||||
if (Convert.ToInt32(pruefen.ExecuteScalar()) > 0)
|
||||
return;
|
||||
}
|
||||
|
||||
using var ergaenzen = connection.CreateCommand();
|
||||
ergaenzen.CommandText = $"ALTER TABLE {tabelle} ADD COLUMN {spalte} {definition}";
|
||||
ergaenzen.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,40 @@ public interface IAgentTool
|
||||
string Description { get; }
|
||||
JsonElement InputSchema { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ob dieses Werkzeug gefahrlos gleichzeitig mit anderen laufen darf.
|
||||
///
|
||||
/// Die Vorgabe ist <c>false</c> — nicht aus Vorsicht um ihrer selbst willen,
|
||||
/// sondern weil die falsche Antwort hier nicht auffällt: Zwei schreibende
|
||||
/// Aufrufe, die sich um dieselbe Datei oder denselben Datensatz streiten,
|
||||
/// scheitern nicht zuverlässig, sondern gelegentlich. Ein Werkzeug gibt sich
|
||||
/// deshalb ausdrücklich frei, und nur dann, wenn es nichts verändert.
|
||||
///
|
||||
/// „Nichts verändert" heißt: kein Schreiben in Dateien, Datenbank oder Fremdsystem
|
||||
/// und keine Abhängigkeit davon, was ein anderer Aufruf desselben Zuges tut.
|
||||
/// Ein reiner Abruf erfüllt das, eine Ablage nicht.
|
||||
/// </summary>
|
||||
bool IsParallelSafe => false;
|
||||
|
||||
/// <summary>
|
||||
/// Ob ein zweiter, gleicher Aufruf schadlos bleibt (#554).
|
||||
///
|
||||
/// <para>Gebraucht wird das nach einem harten Absturz: Im Protokoll steht, dass ein
|
||||
/// Aufruf begann, nicht ob er wirkte. Bei einem wiederholbaren Werkzeug muss man das
|
||||
/// auch nicht wissen — man ruft es einfach noch einmal. Bei allen anderen bleibt nur,
|
||||
/// den Fall vorzulegen statt zu raten.</para>
|
||||
///
|
||||
/// <para>Nicht zu verwechseln mit <see cref="IsParallelSafe"/>. Das heißt „stört
|
||||
/// keinen gleichzeitigen Aufruf", dies hier heißt „richtet zweimal keinen Schaden an".
|
||||
/// Ein Werkzeug kann das eine sein und das andere nicht: Eine Datei zu überschreiben
|
||||
/// ist wiederholbar — zweimal derselbe Inhalt ergibt denselben Zustand —, verträgt
|
||||
/// aber keinen zweiten Schreiber zur selben Zeit.</para>
|
||||
///
|
||||
/// <para>Vorgabe <c>false</c>, aus demselben Grund wie oben: Wer sich irrt, versendet
|
||||
/// die Mail zweimal.</para>
|
||||
/// </summary>
|
||||
bool IsRepeatable => false;
|
||||
|
||||
Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Tools;
|
||||
|
||||
/// <summary>Was die Nachschau über einen unterbrochenen Aufruf ergeben hat.</summary>
|
||||
public enum ProbeOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Nicht feststellbar — und das ist eine vollwertige Antwort, kein Fehlschlag.
|
||||
///
|
||||
/// <para>Die meisten Werkzeuge landen hier, und zwar zu Recht: Ob ein SMTP-Server
|
||||
/// eine Nachricht angenommen hat, lässt sich im Nachhinein nicht feststellen, wenn
|
||||
/// die Gegenseite keine Kennung führt. Ein Werkzeug, das in so einem Fall
|
||||
/// „vermutlich nicht" meldete, wäre schlimmer als eines, das schweigt.</para>
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Nachweislich nichts geschehen — der Aufruf kam nicht bis zur Wirkung.</summary>
|
||||
NotStarted,
|
||||
|
||||
/// <summary>Die Wirkung ist eingetreten, aber unvollständig.</summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>Die Wirkung ist vollständig eingetreten. Wiederholen wäre überflüssig.</summary>
|
||||
Complete
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Befund der Nachschau. <paramref name="Evidence"/> sagt, <b>woran</b> es erkannt
|
||||
/// wurde — der Befund ist Beleg, nicht Urteil, und wer ihn liest, soll ihn beurteilen
|
||||
/// können statt ihm glauben zu müssen.
|
||||
/// </summary>
|
||||
public sealed record ProbeFinding(ProbeOutcome Outcome, string Evidence)
|
||||
{
|
||||
public static ProbeFinding Unknown(string grund) => new(ProbeOutcome.Unknown, grund);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional von einem Werkzeug zu bedienen (#555): Nach einem Absturz die Welt befragen,
|
||||
/// wie weit ein unterbrochener Aufruf gekommen ist.
|
||||
///
|
||||
/// <para>Das Protokoll sagt, dass ein Aufruf begann, nicht ob er wirkte. Manche Werkzeuge
|
||||
/// können diese Lücke selbst schließen, weil ihr Wirkungsort befragbar ist — ein
|
||||
/// Dateisystem ist es, ein fremder Mailserver nicht. Deshalb ist die Schnittstelle
|
||||
/// optional, und ihr Fehlen ist eine Aussage: Bei diesem Werkzeug bleibt es ungewiss.</para>
|
||||
///
|
||||
/// <para><b>Was hier nicht hingehört:</b> die Frage, ob ein Inhalt „mitten im Satz"
|
||||
/// endet, weil das Modell an seine Ausgabegrenze stieß. Die Datei ist dann fehlerfrei
|
||||
/// geschrieben — nur der Inhalt ist halb. Vom Wirkungsort aus lässt sich eine kurze
|
||||
/// Notiz nicht von einem abgeschnittenen Bericht unterscheiden. Diese Auskunft gibt es
|
||||
/// zuverlässig an der Quelle, über <c>finish_reason</c> (#548).</para>
|
||||
/// </summary>
|
||||
public interface IToolStateProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Sieht nach, was der Aufruf mit diesen Argumenten hinterlassen hat.
|
||||
///
|
||||
/// <para>Darf nichts verändern. Wirft nicht: Was sich nicht feststellen lässt, wird
|
||||
/// als <see cref="ProbeOutcome.Unknown"/> gemeldet, mit Begründung.</para>
|
||||
/// </summary>
|
||||
Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.FileRW;
|
||||
|
||||
public sealed class FileRWTool : IAgentTool
|
||||
public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten
|
||||
@@ -200,6 +201,88 @@ public sealed class FileRWTool : IAgentTool
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nachschau nach einem Absturz (#555). Sieht nach, was von einem unterbrochenen
|
||||
/// Aufruf übrig ist — verändert nichts.
|
||||
///
|
||||
/// <para>Wie weit die Auskunft trägt, hängt an der Aktion:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>write</b> — seit #553 atomar. Entweder die alte oder die neue Fassung,
|
||||
/// nie eine halbe. Ein Vergleich mit dem beabsichtigten Inhalt sagt deshalb
|
||||
/// zuverlässig, ob der Aufruf durchkam.</item>
|
||||
/// <item><b>append</b> — nicht atomar und nicht atomar zu machen. Hier kann wirklich
|
||||
/// ein Riss entstehen, und hier lohnt die Nachschau: Steht der beabsichtigte Text
|
||||
/// vollständig am Ende, kam der Aufruf durch; steht ein Anfang davon da, riss es
|
||||
/// mittendrin.</item>
|
||||
/// <item>alles andere — <see cref="ProbeOutcome.Unknown"/>. Lieber schweigen als
|
||||
/// raten.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public async Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = arguments.TryGetProperty("action", out var a) ? a.GetString() : null;
|
||||
|
||||
if (action is not ("write" or "append"))
|
||||
return ProbeFinding.Unknown($"Für die Aktion '{action ?? "?"}' gibt es nichts nachzusehen.");
|
||||
|
||||
var workspace = (arguments.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
|
||||
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
return ProbeFinding.Unknown("Workspace-Pfad ist nicht bekannt.");
|
||||
|
||||
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
|
||||
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Die Datei existiert nicht — geschrieben wurde nichts.");
|
||||
}
|
||||
|
||||
var vorhanden = await File.ReadAllTextAsync(path, ct);
|
||||
|
||||
if (action == "write")
|
||||
{
|
||||
return vorhanden == erwartet
|
||||
? new ProbeFinding(ProbeOutcome.Complete,
|
||||
$"Der Inhalt entspricht genau dem beabsichtigten ({erwartet.Length} Zeichen).")
|
||||
: new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Die Datei trägt noch den alten Inhalt. Seit #553 wird atomar " +
|
||||
"geschrieben — eine halbe Fassung kann es nicht geben.");
|
||||
}
|
||||
|
||||
// append
|
||||
if (vorhanden.EndsWith(erwartet, StringComparison.Ordinal))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.Complete,
|
||||
$"Der beabsichtigte Text steht vollständig am Ende ({erwartet.Length} Zeichen).");
|
||||
}
|
||||
|
||||
// Wie viel vom Erwarteten steht am Ende? Das ist die Rissstelle.
|
||||
for (var laenge = Math.Min(erwartet.Length - 1, vorhanden.Length); laenge > 0; laenge--)
|
||||
{
|
||||
if (vorhanden.EndsWith(erwartet[..laenge], StringComparison.Ordinal))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.Partial,
|
||||
$"Angehängt wurden {laenge} von {erwartet.Length} Zeichen — " +
|
||||
"der Aufruf riss mittendrin ab.");
|
||||
}
|
||||
}
|
||||
|
||||
return new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Vom beabsichtigten Text steht nichts am Ende der Datei.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Nie werfen: Eine gescheiterte Nachschau ist keine Aussage über die Welt,
|
||||
// sondern eine über die Nachschau.
|
||||
return ProbeFinding.Unknown($"Nachschau nicht möglich: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context)
|
||||
{
|
||||
var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : "";
|
||||
@@ -268,7 +351,12 @@ public sealed class FileRWTool : IAgentTool
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
// Atomar schreiben (#553): erst in eine Nebendatei, dann ersetzen. Ein Absturz
|
||||
// mitten im Vorgang hinterlässt damit entweder die alte oder die neue Fassung,
|
||||
// nie eine halbe. Zuvor stand hier File.WriteAllTextAsync — die Anwendung
|
||||
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
|
||||
// Agenten aber nicht.
|
||||
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Datei erfolgreich geschrieben: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
finally
|
||||
@@ -286,6 +374,10 @@ public sealed class FileRWTool : IAgentTool
|
||||
await fileLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Bewusst NICHT atomar (#553): Anhängen ließe sich nur atomar machen, indem
|
||||
// die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre
|
||||
// das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es
|
||||
// die Zustandsprüfung in #555.
|
||||
await File.AppendAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,25 @@ public sealed class WebFetchTool : IAgentTool
|
||||
Aktionen: fetch, rss
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Freigegeben: Dieses Werkzeug liest ausschließlich. Beide Aktionen enden in einem
|
||||
/// <c>GetAsync</c>; es gibt keinen Schreibzugriff — weder auf Dateien noch auf den
|
||||
/// StateStore. Drei Abrufe gleichzeitig unterscheiden sich im Ergebnis durch nichts
|
||||
/// von drei Abrufen nacheinander.
|
||||
///
|
||||
/// <para>Zum Vergleich das Nachbarwerkzeug WebMonitor: Es sieht ähnlich aus, merkt
|
||||
/// sich aber den zuletzt gesehenen Stand über <c>StateStore.SetAsync</c> und bleibt
|
||||
/// deshalb seriell.</para>
|
||||
/// </summary>
|
||||
public bool IsParallelSafe => true;
|
||||
|
||||
/// <summary>
|
||||
/// Wiederholbar (#554): Ein zweiter Abruf derselben Adresse ändert nichts an der
|
||||
/// Welt. Die Antwort kann inzwischen eine andere sein — das ist aber kein Schaden,
|
||||
/// sondern der Zweck eines Abrufs.
|
||||
/// </summary>
|
||||
public bool IsRepeatable => true;
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
|
||||
@@ -8,6 +8,15 @@ namespace ClawdDotNet.Core.Tests.Audit;
|
||||
/// A3 an der Engine: Jeder Tool-Aufruf wird protokolliert, jeder Lauf bekommt einen Beleg.
|
||||
/// Entscheidend ist die Provenienz — die Engine stempelt Agent, Modell und Herkunft aus
|
||||
/// ihrem eigenen Wissen, nicht aus dem Tool-Ergebnis.
|
||||
///
|
||||
/// <para>Seit #554 erzeugt ein ausgefuehrter Aufruf ZWEI Eintraege: erst
|
||||
/// <see cref="AuditStatus.Started"/> vor der Wirkung, dann den Ausgang. Die Tests
|
||||
/// hier filtern deshalb auf den Abschluss, wo es um den Ausgang geht — dass der
|
||||
/// Beginn mitgeschrieben wird, pruefen die WiederaufnahmeTests.</para>
|
||||
///
|
||||
/// <para>Ein Aufruf, der gar nicht erst laeuft (verweigert, unbekannt, vorgelegt),
|
||||
/// hat weiterhin genau einen Eintrag: Der Beginn wird erst nach Rechtepruefung und
|
||||
/// Staging geschrieben.</para>
|
||||
/// </summary>
|
||||
public sealed class EngineAuditTests
|
||||
{
|
||||
@@ -27,9 +36,17 @@ public sealed class EngineAuditTests
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
|
||||
|
||||
var entries = audit.Entries.ToList();
|
||||
entries.Count.ShouldBe(2);
|
||||
|
||||
// Zwei Aufrufe, je ein Beginn und ein Abschluss.
|
||||
entries.Count.ShouldBe(4);
|
||||
entries.ShouldAllBe(e => e.Tool == "TestTool");
|
||||
entries.ShouldAllBe(e => e.Status == AuditStatus.Ok);
|
||||
entries.Count(e => e.Status == AuditStatus.Started).ShouldBe(2);
|
||||
entries.Count(e => e.Status == AuditStatus.Ok).ShouldBe(2);
|
||||
|
||||
// Jeder Beginn gehoert zu genau einem Abschluss.
|
||||
entries.Where(e => e.Status == AuditStatus.Started).Select(e => e.CallId)
|
||||
.ShouldBe(entries.Where(e => e.Status == AuditStatus.Ok).Select(e => e.CallId),
|
||||
ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -42,7 +59,7 @@ public sealed class EngineAuditTests
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-7");
|
||||
|
||||
var entry = audit.Entries.Single();
|
||||
var entry = audit.Entries.Single(e => e.Status == AuditStatus.Ok);
|
||||
entry.AgentId.ShouldBe("agent-a");
|
||||
entry.Model.ShouldBe("test/model");
|
||||
entry.Source.ShouldBe("task");
|
||||
@@ -74,7 +91,7 @@ public sealed class EngineAuditTests
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
|
||||
|
||||
var entry = audit.Entries.Single();
|
||||
var entry = audit.Entries.Single(e => e.Status != AuditStatus.Started);
|
||||
entry.Status.ShouldBe(AuditStatus.Error);
|
||||
entry.Summary.ShouldContain("kaputt");
|
||||
}
|
||||
@@ -108,7 +125,7 @@ public sealed class EngineAuditTests
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
|
||||
|
||||
var runId = audit.Receipts.Single().RunId;
|
||||
audit.Entries.Single().RunId.ShouldBe(runId, "alle Aufrufe eines Laufs tragen dieselbe RunId");
|
||||
audit.Entries.ShouldAllBe(e => e.RunId == runId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Wiederaufnahme nach hartem Absturz (#554) und die Auswertung des Protokolls (#556).
|
||||
/// </summary>
|
||||
public class WiederaufnahmeTests
|
||||
{
|
||||
// ─────────────── Der "angefangen"-Eintrag ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Vor_dem_Werkzeug_steht_ein_Eintrag_im_Protokoll()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
|
||||
var agent = fixture.AddAgent("a1", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool", id: "c1")
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
var eintraege = audit.Entries.Where(e => e.CallId == "c1").ToList();
|
||||
|
||||
// Genau zwei: erst der Beginn, dann der Abschluss — in dieser Reihenfolge.
|
||||
Assert.Equal(2, eintraege.Count);
|
||||
Assert.Equal(AuditStatus.Started, eintraege[0].Status);
|
||||
Assert.Equal(AuditStatus.Ok, eintraege[1].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_abgeschlossener_Aufruf_gilt_nicht_als_offen()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
|
||||
var agent = fixture.AddAgent("a1", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool", id: "c1")
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Abbruch_laesst_den_Aufruf_nicht_offen_stehen()
|
||||
{
|
||||
// Sonst sähe jeder gewöhnliche Benutzerabbruch beim nächsten Start aus wie
|
||||
// ein Absturz — und niemand sähe mehr hin.
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var fixture = new EngineFixture(audit).WithTool(
|
||||
FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool"));
|
||||
var agent = fixture.AddAgent("a1", "LangsamTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("LangsamTool", id: "c1")
|
||||
.AlwaysRespondsWithText("Fertig.");
|
||||
|
||||
var lauf = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
fixture.Engine.AbortChat("a1");
|
||||
await lauf;
|
||||
|
||||
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
|
||||
|
||||
var letzter = audit.Entries.Last(e => e.CallId == "c1");
|
||||
Assert.Equal(AuditStatus.Cancelled, letzter.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Aufruf_ohne_Abschluss_wird_als_offen_gefunden()
|
||||
{
|
||||
// Simuliert den harten Absturz: Der Beginn steht im Protokoll, mehr nicht.
|
||||
var audit = new InMemoryAuditRepository();
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Mail",
|
||||
Arguments = """{"to":"x@example.com"}""",
|
||||
Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
var offen = await audit.ListOpenCallsAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(offen);
|
||||
Assert.Equal("Mail", offen[0].Tool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Alteintraege_ohne_CallId_gelten_nicht_als_offen()
|
||||
{
|
||||
// Sonst legte der erste Start nach der Aktualisierung das ganze Altprotokoll
|
||||
// zur Prüfung vor.
|
||||
var audit = new InMemoryAuditRepository();
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "", AgentId = "a1", Tool = "Mail",
|
||||
Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
// ─────────────── Die Einstufung ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_wiederholbares_Werkzeug_braucht_keine_Entscheidung()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var registry = new ToolRegistry();
|
||||
registry.Register(new WiederholbaresTool());
|
||||
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Abruf",
|
||||
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
var recovery = new CrashRecovery(audit, registry, TestLogging.Factory);
|
||||
var faelle = await recovery.SichtenAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(faelle);
|
||||
Assert.Equal(RecoveryVerdict.Repeatable, faelle[0].Verdict);
|
||||
Assert.True(faelle[0].Erledigt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_nicht_wiederholbares_Werkzeug_wird_vorgelegt()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var registry = new ToolRegistry();
|
||||
registry.Register(FakeTool.Returning("ok", "Versand")); // Vorgabe: nicht wiederholbar
|
||||
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Versand",
|
||||
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
var recovery = new CrashRecovery(audit, registry, TestLogging.Factory);
|
||||
var faelle = await recovery.SichtenAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(RecoveryVerdict.NeedsReview, faelle[0].Verdict);
|
||||
Assert.False(faelle[0].Erledigt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_verschwundenes_Werkzeug_wird_als_solches_gemeldet()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "GibtsNichtMehr",
|
||||
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
var recovery = new CrashRecovery(audit, new ToolRegistry(), TestLogging.Factory);
|
||||
var faelle = await recovery.SichtenAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(RecoveryVerdict.ToolUnknown, faelle[0].Verdict);
|
||||
Assert.False(faelle[0].Erledigt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_geordnetem_Ende_ist_nichts_zu_sichten()
|
||||
{
|
||||
var recovery = new CrashRecovery(
|
||||
new InMemoryAuditRepository(), new ToolRegistry(), TestLogging.Factory);
|
||||
|
||||
Assert.Empty(await recovery.SichtenAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
// ─────────────── Die Auswertung (#556) ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Kennzahlen_zaehlen_nur_abgeschlossene_Aufrufe()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var jetzt = DateTime.Now;
|
||||
|
||||
// Zwei vollständige Aufrufe à 100 ms, einer davon gescheitert,
|
||||
// plus ein dritter, der nie endete.
|
||||
foreach (var (call, status, dauer) in new[]
|
||||
{
|
||||
("c1", AuditStatus.Ok, 100L),
|
||||
("c2", AuditStatus.Error, 100L),
|
||||
})
|
||||
{
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
CallId = call, AgentId = "a1", Tool = "T", Status = AuditStatus.Started,
|
||||
DurationMs = 0, OccurredAt = jetzt
|
||||
}, CancellationToken.None);
|
||||
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
CallId = call, AgentId = "a1", Tool = "T", Status = status,
|
||||
DurationMs = dauer, OccurredAt = jetzt
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
CallId = "c3", AgentId = "a1", Tool = "T", Status = AuditStatus.Started,
|
||||
DurationMs = 0, OccurredAt = jetzt
|
||||
}, CancellationToken.None);
|
||||
|
||||
var stats = await audit.SummarizeToolUsageAsync("a1", null, CancellationToken.None);
|
||||
var t = stats.Single(s => s.Tool == "T");
|
||||
|
||||
Assert.Equal(2, t.Calls);
|
||||
Assert.Equal(1, t.Failed);
|
||||
Assert.Equal(1, t.Unfinished);
|
||||
|
||||
// 200 ms auf zwei abgeschlossene Aufrufe. Zählten die drei Started-Einträge
|
||||
// mit ihrer Dauer 0 mit, stünde hier 40 — das Werkzeug sähe doppelt so
|
||||
// schnell aus, wie es ist.
|
||||
Assert.Equal(100d, t.AverageDurationMs);
|
||||
Assert.Equal(0.5d, t.FailureRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Abfrage_je_Agent_trennt_die_Agenten()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
|
||||
foreach (var agent in new[] { "a1", "a1", "a2" })
|
||||
{
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
CallId = Guid.NewGuid().ToString("N"), AgentId = agent, Tool = "T",
|
||||
Status = AuditStatus.Ok, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
var fuerA1 = await audit.ListForAgentAsync("a1", null, 100, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, fuerA1.Count);
|
||||
Assert.All(fuerA1, e => Assert.Equal("a1", e.AgentId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ein Werkzeug, das sich als wiederholbar ausgibt.</summary>
|
||||
internal sealed class WiederholbaresTool : IAgentTool
|
||||
{
|
||||
public string Name => "Abruf";
|
||||
public string Description => "Liest nur.";
|
||||
public bool IsRepeatable => true;
|
||||
|
||||
public System.Text.Json.JsonElement InputSchema { get; } =
|
||||
System.Text.Json.JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone();
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(
|
||||
System.Text.Json.JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
=> Task.FromResult(ToolResult.Ok("ok"));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Was ein Abbruch mitten im Zug mit dem Gesprächskontext macht.
|
||||
///
|
||||
/// Der Kontext ist eine geteilte, an Ort und Stelle veränderte Liste. Bricht ein Lauf
|
||||
/// zwischen dem Werkzeugaufruf und seiner Antwort ab, bleibt eine assistant-Nachricht
|
||||
/// mit tool_calls stehen, auf die keine Antwort folgt — eine Nachrichtenfolge, die die
|
||||
/// Schnittstelle nicht annimmt.
|
||||
/// </summary>
|
||||
public class AbbruchKontextTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Ein_Abbruch_waehrend_des_Werkzeugs_hinterlaesst_keinen_unbeantworteten_Aufruf()
|
||||
{
|
||||
var fixture = new EngineFixture().WithTool(
|
||||
FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool"));
|
||||
|
||||
var agent = fixture.AddAgent("a1", "LangsamTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("LangsamTool")
|
||||
.AlwaysRespondsWithText("Fertig.");
|
||||
|
||||
var lauf = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
// Warten, bis der Aufruf sicher steht und das Werkzeug läuft.
|
||||
await Task.Delay(300);
|
||||
fixture.Engine.AbortChat("a1");
|
||||
|
||||
var result = await lauf;
|
||||
Assert.Equal(AgentRunStatus.Cancelled, result.Status);
|
||||
|
||||
var kontext = fixture.Engine.GetChatContext("a1");
|
||||
|
||||
var offeneAufrufe = kontext
|
||||
.Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 })
|
||||
.SelectMany(m => m.ToolCalls!)
|
||||
.Select(tc => tc.Id)
|
||||
.Where(id => !kontext.Any(m => m.Role == "tool" && m.ToolCallId == id))
|
||||
.ToList();
|
||||
|
||||
Assert.True(offeneAufrufe.Count == 0,
|
||||
$"Unbeantwortete Werkzeugaufrufe im Kontext: {string.Join(", ", offeneAufrufe)}. " +
|
||||
"Eine solche Folge lehnt die Schnittstelle mit HTTP 400 ab — der Agent ist " +
|
||||
"danach dauerhaft blockiert.");
|
||||
|
||||
// Die Ersatzantwort sagt auch, was passiert ist. Eine leere Antwort wäre formal
|
||||
// gültig, ließe das Modell aber rätseln, warum sein Aufruf nichts ergab.
|
||||
var ersatz = kontext.Last(m => m.Role == "tool");
|
||||
Assert.Contains("abgebrochen", ersatz.Content, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_einem_Abbruch_laesst_sich_der_Agent_wieder_ansprechen()
|
||||
{
|
||||
// Der eigentliche Punkt: Nicht die Form des Kontexts zählt, sondern ob der
|
||||
// Agent danach noch arbeitet. Ein zweiter Chat muss durchlaufen.
|
||||
var fixture = new EngineFixture().WithTool(
|
||||
FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool"));
|
||||
|
||||
var agent = fixture.AddAgent("a1", "LangsamTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("LangsamTool")
|
||||
.AlwaysRespondsWithText("Zweite Antwort.");
|
||||
|
||||
var erster = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
fixture.Engine.AbortChat("a1");
|
||||
await erster;
|
||||
|
||||
var zweiter = await fixture.Engine.ChatAsync(agent, "Und jetzt?", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Equal(AgentRunStatus.Completed, zweiter.Status);
|
||||
Assert.Equal("Zweite Antwort.", zweiter.FinalMessage);
|
||||
|
||||
// Und die Folge, die dabei gesendet wurde, war vollständig beantwortet.
|
||||
var gesendet = fixture.Client.ReceivedRequests[^1].Messages;
|
||||
var offen = gesendet
|
||||
.Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 })
|
||||
.SelectMany(m => m.ToolCalls!)
|
||||
.Select(tc => tc.Id)
|
||||
.Where(id => !gesendet.Any(m => m.Role == "tool" && m.ToolCallId == id))
|
||||
.ToList();
|
||||
|
||||
Assert.Empty(offen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Api.Models;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Die Härtung des Harness gegen stillen Verlust: #547 bis #551.
|
||||
///
|
||||
/// Was diese fünf Befunde verbindet, ist nicht der Ort im Code, sondern der
|
||||
/// Fehlermodus — keiner von ihnen erzeugt eine Fehlermeldung. Der Lauf wird fertig,
|
||||
/// das Ergebnis sieht plausibel aus, und nur die Qualität ist schlechter. Genau
|
||||
/// deshalb braucht jeder einen Test: Von Hand fällt so etwas nicht auf.
|
||||
/// </summary>
|
||||
public class HarnessHaertungTests
|
||||
{
|
||||
// ─────────────────────────── #547 ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Begleittext_zum_Werkzeugaufruf_bleibt_im_Verlauf()
|
||||
{
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis"));
|
||||
var agent = fixture.AddAgent("a1", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCallAndContent("TestTool", "Ich sehe zuerst in der Konfiguration nach.")
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
// Die zweite Anfrage trägt den Verlauf, den die erste erzeugt hat.
|
||||
var verlauf = fixture.Client.ReceivedRequests[1].Messages;
|
||||
var mitAufruf = verlauf.Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 });
|
||||
|
||||
Assert.Equal("Ich sehe zuerst in der Konfiguration nach.", mitAufruf.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Begleittext_bleibt_die_Nachricht_ohne_Inhalt()
|
||||
{
|
||||
// Gegenprobe: Ein leerer Text darf nicht als "" mitgehen. Manche Anbieter
|
||||
// lehnen eine assistant-Nachricht mit leerem Inhalt und tool_calls ab.
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis"));
|
||||
var agent = fixture.AddAgent("a1", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool")
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
var mitAufruf = fixture.Client.ReceivedRequests[1].Messages
|
||||
.Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 });
|
||||
|
||||
Assert.Null(mitAufruf.Content);
|
||||
}
|
||||
|
||||
// ─────────────────────────── #548 ───────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("length")]
|
||||
[InlineData("max_tokens")]
|
||||
[InlineData("LENGTH")]
|
||||
public void Ein_Abbruch_an_der_Ausgabegrenze_wird_erkannt(string grund)
|
||||
=> Assert.True(AgentEngine.IsTruncated(grund));
|
||||
|
||||
[Theory]
|
||||
[InlineData("stop")]
|
||||
[InlineData("tool_calls")]
|
||||
[InlineData("end_turn")]
|
||||
[InlineData(null)]
|
||||
public void Ein_regulaeres_Ende_gilt_nicht_als_Abbruch(string? grund)
|
||||
=> Assert.False(AgentEngine.IsTruncated(grund));
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_abgeschnittene_Antwort_wird_nicht_als_fertig_gemeldet()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1");
|
||||
|
||||
fixture.Client.RespondsWithTruncatedText("Der Bericht beginnt mit");
|
||||
|
||||
var result = await fixture.Engine.RunAsync(agent, "Bericht", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Equal(AgentRunStatus.Truncated, result.Status);
|
||||
|
||||
// Das Teilergebnis geht trotzdem mit — es wegzuwerfen wäre schlimmer, als es
|
||||
// gekennzeichnet weiterzureichen.
|
||||
Assert.Equal("Der Bericht beginnt mit", result.FinalMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_vollstaendige_Antwort_bleibt_Completed()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1");
|
||||
|
||||
fixture.Client.RespondsWithText("Fertiger Bericht.");
|
||||
|
||||
var result = await fixture.Engine.RunAsync(agent, "Bericht", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Equal(AgentRunStatus.Completed, result.Status);
|
||||
}
|
||||
|
||||
// ─────────────────────────── #549 ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Denkverlauf_geht_unveraendert_zurueck()
|
||||
{
|
||||
const string details = """
|
||||
[{"type":"reasoning.text","text":"Erst die Konfiguration prüfen.","id":"r1","format":"anthropic-claude-v1","index":0,"signature":"sha256:abc"}]
|
||||
""";
|
||||
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis"));
|
||||
var agent = fixture.AddAgent("a1", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCallAndContent("TestTool", "Ich prüfe.", reasoningDetailsJson: details)
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
var mitAufruf = fixture.Client.ReceivedRequests[1].Messages
|
||||
.Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 });
|
||||
|
||||
Assert.NotNull(mitAufruf.ReasoningDetails);
|
||||
|
||||
// Signatur und Reihenfolge müssen erhalten sein — daran erkennt der Anbieter
|
||||
// seinen eigenen Block wieder.
|
||||
var block = mitAufruf.ReasoningDetails!.Value[0];
|
||||
Assert.Equal("reasoning.text", block.GetProperty("type").GetString());
|
||||
Assert.Equal("sha256:abc", block.GetProperty("signature").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Der_Denkverlauf_ueberlebt_Serialisierung_und_Rueckweg()
|
||||
{
|
||||
// Der Verlauf wird auf die Platte geschrieben und beim Start wieder geladen.
|
||||
// Überlebt der Denkverlauf das nicht, ist er nach einem Neustart weg.
|
||||
const string details = """
|
||||
[{"type":"reasoning.encrypted","data":"AAAA","id":"r1","index":0}]
|
||||
""";
|
||||
|
||||
var original = ChatMessage.AssistantWithToolCalls(
|
||||
[new ToolCall { Id = "c1", Type = "function", Function = new ToolCallFunction { Name = "T", Arguments = "{}" } }],
|
||||
content: "Text",
|
||||
reasoningDetails: JsonDocument.Parse(details).RootElement.Clone());
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var zurueck = JsonSerializer.Deserialize<ChatMessage>(json)!;
|
||||
|
||||
Assert.Equal("Text", zurueck.Content);
|
||||
Assert.NotNull(zurueck.ReasoningDetails);
|
||||
Assert.Equal("reasoning.encrypted", zurueck.ReasoningDetails!.Value[0].GetProperty("type").GetString());
|
||||
Assert.Equal("AAAA", zurueck.ReasoningDetails!.Value[0].GetProperty("data").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Denkverlauf_steht_kein_leeres_Feld_in_der_Anfrage()
|
||||
{
|
||||
var ohne = ChatMessage.Assistant("Nur Text");
|
||||
var json = JsonSerializer.Serialize(ohne);
|
||||
|
||||
Assert.DoesNotContain("reasoning", json);
|
||||
}
|
||||
|
||||
// ─────────────────────────── #550 ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Anfrage_verlangt_einen_Anbieter_der_alles_beherrscht()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1");
|
||||
fixture.Client.RespondsWithText("ok");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
var request = fixture.Client.ReceivedRequests[0];
|
||||
|
||||
Assert.True(request.Provider?.RequireParameters);
|
||||
Assert.True(request.Usage?.Include);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Anbieterbindung_laesst_sich_je_Agent_abschalten()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1");
|
||||
agent.RequireProviderParameters = false;
|
||||
fixture.Client.RespondsWithText("ok");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Null(fixture.Client.ReceivedRequests[0].Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Denktiefe_geht_kein_reasoning_Feld_mit()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1"); // Vorgabe ist "off"
|
||||
fixture.Client.RespondsWithText("ok");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Null(fixture.Client.ReceivedRequests[0].Reasoning);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("high", "high", null)]
|
||||
[InlineData("MAX", "max", null)]
|
||||
[InlineData("4096", null, 4096)]
|
||||
public async Task Die_Denktiefe_wird_richtig_uebersetzt(string einstellung, string? effort, int? tokens)
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1");
|
||||
agent.ReasoningEffort = einstellung;
|
||||
fixture.Client.RespondsWithText("ok");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
var reasoning = fixture.Client.ReceivedRequests[0].Reasoning;
|
||||
Assert.NotNull(reasoning);
|
||||
Assert.Equal(effort, reasoning!.Effort);
|
||||
Assert.Equal(tokens, reasoning.MaxTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_gemeldete_Betrag_wird_uebernommen_statt_geschaetzt()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1");
|
||||
|
||||
fixture.Client.RespondsWithText("ok", new Usage
|
||||
{
|
||||
PromptTokens = 100,
|
||||
CompletionTokens = 20,
|
||||
TotalTokens = 120,
|
||||
Cost = 0.0042m
|
||||
});
|
||||
|
||||
var result = await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Equal(0.0042m, result.ReportedCostUsd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_gemeldeten_Betrag_bleibt_das_Feld_leer()
|
||||
{
|
||||
// Wichtig: nicht 0. Ein als kostenlos verbuchter Lauf wäre schlimmer als
|
||||
// gar keine Angabe, weil er die Budgetrechnung still verfälscht.
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1");
|
||||
fixture.Client.RespondsWithText("ok");
|
||||
|
||||
var result = await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Null(result.ReportedCostUsd);
|
||||
}
|
||||
|
||||
// ─────────────────────────── #551 ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Freigegebene_Werkzeuge_laufen_tatsaechlich_gleichzeitig()
|
||||
{
|
||||
var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(300));
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var agent = fixture.AddAgent("a1", "ParallelTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCalls(
|
||||
("ParallelTool", "{}", "c1"),
|
||||
("ParallelTool", "{}", "c2"),
|
||||
("ParallelTool", "{}", "c3"))
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
// Der belastbare Nachweis: Aufrufe haben sich überlappt.
|
||||
Assert.True(tool.MaxConcurrent > 1,
|
||||
$"Erwartet: überlappende Aufrufe. Höchststand war {tool.MaxConcurrent}.");
|
||||
|
||||
// Die Laufzeit als zweite, schwächere Bestätigung — seriell wären es 900 ms.
|
||||
Assert.True(sw.ElapsedMilliseconds < 750,
|
||||
$"Erwartet: deutlich unter 900 ms. Gemessen: {sw.ElapsedMilliseconds} ms.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nicht_freigegebene_Werkzeuge_bleiben_seriell()
|
||||
{
|
||||
var tool = FakeTool.SlowSerial(TimeSpan.FromMilliseconds(80));
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var agent = fixture.AddAgent("a1", "SerialTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCalls(
|
||||
("SerialTool", "{}", "c1"),
|
||||
("SerialTool", "{}", "c2"),
|
||||
("SerialTool", "{}", "c3"))
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, tool.MaxConcurrent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Reihenfolge_der_Ergebnisse_folgt_den_Aufrufen()
|
||||
{
|
||||
// Der langsamste Aufruf kommt zuerst. Würde die Engine nach Fertigstellung
|
||||
// einsortieren, stünde er am Ende — und jede tool_call_id wäre falsch zugeordnet.
|
||||
var tool = new StaffelTool();
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var agent = fixture.AddAgent("a1", "StaffelTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCalls(
|
||||
("StaffelTool", """{"delay":250,"marke":"erste"}""", "c1"),
|
||||
("StaffelTool", """{"delay":10,"marke":"zweite"}""", "c2"),
|
||||
("StaffelTool", """{"delay":120,"marke":"dritte"}""", "c3"))
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
var antworten = fixture.Client.ReceivedRequests[1].Messages
|
||||
.Where(m => m.Role == "tool")
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(["c1", "c2", "c3"], antworten.Select(m => m.ToolCallId));
|
||||
Assert.Equal(["erste", "zweite", "dritte"], antworten.Select(m => m.Content));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Grenze_der_Nebenlaeufigkeit_wird_eingehalten()
|
||||
{
|
||||
var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(120));
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var agent = fixture.AddAgent("a1", "ParallelTool");
|
||||
agent.MaxParallelToolCalls = 2;
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCalls(
|
||||
("ParallelTool", "{}", "c1"),
|
||||
("ParallelTool", "{}", "c2"),
|
||||
("ParallelTool", "{}", "c3"),
|
||||
("ParallelTool", "{}", "c4"))
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.True(tool.MaxConcurrent <= 2,
|
||||
$"Grenze war 2, gleichzeitig liefen aber {tool.MaxConcurrent}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bei_Grenze_eins_bleibt_alles_seriell()
|
||||
{
|
||||
var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(60));
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var agent = fixture.AddAgent("a1", "ParallelTool");
|
||||
agent.MaxParallelToolCalls = 1;
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCalls(
|
||||
("ParallelTool", "{}", "c1"),
|
||||
("ParallelTool", "{}", "c2"))
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, tool.MaxConcurrent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Werkzeug, das nach einer im Aufruf angegebenen Zeit eine Marke zurückgibt.
|
||||
/// Damit lässt sich prüfen, ob die Reihenfolge der Ergebnisse den Aufrufen folgt
|
||||
/// und nicht der Fertigstellung.
|
||||
/// </summary>
|
||||
internal sealed class StaffelTool : ClawdDotNet.Core.Tools.IAgentTool
|
||||
{
|
||||
public string Name => "StaffelTool";
|
||||
public string Description => "Wartet und meldet eine Marke.";
|
||||
public bool IsParallelSafe => true;
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{ "type": "object", "properties": { "delay": { "type": "integer" }, "marke": { "type": "string" } } }
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ClawdDotNet.Core.Tools.ToolResult> ExecuteAsync(
|
||||
JsonElement input, ClawdDotNet.Core.Tools.AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var delay = input.GetProperty("delay").GetInt32();
|
||||
var marke = input.GetProperty("marke").GetString()!;
|
||||
await Task.Delay(delay, ct);
|
||||
return ClawdDotNet.Core.Tools.ToolResult.Ok(marke);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,52 @@ internal sealed class FakeChatClient : IChatCompletionClient
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwortet mit einem an der Ausgabegrenze abgeschnittenen Text (#548).
|
||||
/// </summary>
|
||||
public FakeChatClient RespondsWithTruncatedText(string text, string finishReason = "length")
|
||||
{
|
||||
_responses.Enqueue(_ => new ChatResponse
|
||||
{
|
||||
Id = "resp",
|
||||
Choices =
|
||||
[
|
||||
new Choice
|
||||
{
|
||||
Index = 0,
|
||||
Message = new ChatMessage { Role = "assistant", Content = text },
|
||||
FinishReason = finishReason
|
||||
}
|
||||
],
|
||||
Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 }
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwortet mit Werkzeugaufrufen UND Begleittext bzw. Denkverlauf — der Fall, den
|
||||
/// #547 und #549 betreffen.
|
||||
/// </summary>
|
||||
public FakeChatClient RespondsWithToolCallAndContent(
|
||||
string toolName,
|
||||
string content,
|
||||
string? reasoning = null,
|
||||
string? reasoningDetailsJson = null,
|
||||
string argumentsJson = "{}")
|
||||
{
|
||||
_responses.Enqueue(_ =>
|
||||
{
|
||||
var response = ToolCallResponse([(toolName, argumentsJson, null)]);
|
||||
var message = response.Choices[0].Message;
|
||||
message.Content = content;
|
||||
message.Reasoning = reasoning;
|
||||
if (reasoningDetailsJson is not null)
|
||||
message.ReasoningDetails = JsonDocument.Parse(reasoningDetailsJson).RootElement.Clone();
|
||||
return response;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Antwort für alle Aufrufe, die über die programmierte Folge hinausgehen.</summary>
|
||||
public FakeChatClient AlwaysRespondsWithText(string text)
|
||||
{
|
||||
@@ -143,6 +189,11 @@ internal sealed class FakeChatClient : IChatCompletionClient
|
||||
MaxTokens = request.MaxTokens,
|
||||
ToolChoice = request.ToolChoice,
|
||||
Tools = request.Tools?.ToList(),
|
||||
// Die Gateway-Felder gehören mitkopiert, sonst prüft ein Test die Härtung
|
||||
// am Wire-Format vorbei — dieselbe Falle wie bei CacheBreakpoint.
|
||||
Provider = request.Provider,
|
||||
Usage = request.Usage,
|
||||
Reasoning = request.Reasoning,
|
||||
Messages = request.Messages.Select(CloneMessage).ToList()
|
||||
};
|
||||
|
||||
@@ -153,6 +204,8 @@ internal sealed class FakeChatClient : IChatCompletionClient
|
||||
ToolCallId = m.ToolCallId,
|
||||
// Muss mitkopiert werden, sonst prüfen Caching-Tests am Wire-Format vorbei.
|
||||
CacheBreakpoint = m.CacheBreakpoint,
|
||||
Reasoning = m.Reasoning,
|
||||
ReasoningDetails = m.ReasoningDetails,
|
||||
ToolCalls = m.ToolCalls?.Select(tc => new ToolCall
|
||||
{
|
||||
Id = tc.Id,
|
||||
|
||||
@@ -22,12 +22,47 @@ internal sealed class FakeTool : IAgentTool
|
||||
/// <summary>Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id.</summary>
|
||||
public ConcurrentBag<string> Invocations { get; } = new();
|
||||
|
||||
/// <summary>Ob das Werkzeug sich für gleichzeitige Ausführung freigibt (#551).</summary>
|
||||
public bool IsParallelSafe { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Höchste Zahl gleichzeitig laufender Aufrufe, die dieses Werkzeug gesehen hat.
|
||||
///
|
||||
/// Das ist der einzige Nachweis, der zählt: Eine kürzere Gesamtlaufzeit könnte auch
|
||||
/// von einer schnellen Maschine kommen, aber ein Höchststand von 2 kann nur
|
||||
/// entstehen, wenn zwei Aufrufe einander tatsächlich überlappt haben.
|
||||
/// </summary>
|
||||
public int MaxConcurrent => _maxConcurrent;
|
||||
|
||||
private int _current;
|
||||
private int _maxConcurrent;
|
||||
|
||||
private FakeTool(string name, Func<JsonElement, AgentToolContext, CancellationToken, Task<ToolResult>> handler)
|
||||
{
|
||||
Name = name;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein langsames Werkzeug, das sich für Nebenläufigkeit freigibt und mitzählt,
|
||||
/// wie viele Aufrufe sich überlappt haben.
|
||||
/// </summary>
|
||||
public static FakeTool SlowParallelSafe(TimeSpan delay, string result = "ok", string name = "ParallelTool")
|
||||
=> new(name, async (_, _, ct) =>
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
return ToolResult.Ok(result);
|
||||
})
|
||||
{ IsParallelSafe = true };
|
||||
|
||||
/// <summary>Dasselbe, aber ohne Freigabe — muss seriell bleiben.</summary>
|
||||
public static FakeTool SlowSerial(TimeSpan delay, string result = "ok", string name = "SerialTool")
|
||||
=> new(name, async (_, _, ct) =>
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
return ToolResult.Ok(result);
|
||||
});
|
||||
|
||||
public static FakeTool Returning(string result, string name = "TestTool")
|
||||
=> new(name, (_, _, _) => Task.FromResult(ToolResult.Ok(result)));
|
||||
|
||||
@@ -42,10 +77,26 @@ internal sealed class FakeTool : IAgentTool
|
||||
public static FakeTool Throwing(Exception ex, string name = "TestTool")
|
||||
=> new(name, (_, _, _) => throw ex);
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
Invocations.Add(context.AgentId);
|
||||
return _handler(input, context, ct);
|
||||
|
||||
var now = Interlocked.Increment(ref _current);
|
||||
|
||||
// Höchststand fortschreiben, ohne ihn je zu senken: Vergleichen und Tauschen,
|
||||
// bis der gespeicherte Wert nicht mehr kleiner ist.
|
||||
int seen;
|
||||
while (now > (seen = Volatile.Read(ref _maxConcurrent)))
|
||||
Interlocked.CompareExchange(ref _maxConcurrent, now, seen);
|
||||
|
||||
try
|
||||
{
|
||||
return await _handler(input, context, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,4 +37,58 @@ internal sealed class InMemoryAuditRepository : IAuditRepository
|
||||
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList());
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
|
||||
|
||||
/// <summary>Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId.</summary>
|
||||
public Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct)
|
||||
{
|
||||
var alle = Entries.ToList();
|
||||
|
||||
var abgeschlossen = alle
|
||||
.Where(e => e.Status != AuditStatus.Started && e.CallId.Length > 0)
|
||||
.Select(e => e.CallId)
|
||||
.ToHashSet();
|
||||
|
||||
var offen = alle
|
||||
.Where(e => e.Status == AuditStatus.Started
|
||||
&& e.CallId.Length > 0
|
||||
&& !abgeschlossen.Contains(e.CallId))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEntry>>(offen);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
|
||||
string agentId, DateTime? seit, int limit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<AuditEntry>>(
|
||||
Entries.Where(e => e.AgentId == agentId)
|
||||
.Where(e => seit is null || e.OccurredAt >= seit)
|
||||
.Reverse()
|
||||
.Take(limit)
|
||||
.ToList());
|
||||
|
||||
public Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
|
||||
string? agentId, DateTime? seit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<ToolUsageStat>>(
|
||||
Entries.Where(e => agentId is null || e.AgentId == agentId)
|
||||
.Where(e => seit is null || e.OccurredAt >= seit)
|
||||
.GroupBy(e => (e.AgentId, e.Tool))
|
||||
.Select(g =>
|
||||
{
|
||||
var abgeschlossen = g.Where(e => e.Status != AuditStatus.Started).ToList();
|
||||
var begonnen = g.Count(e => e.Status == AuditStatus.Started);
|
||||
|
||||
return new ToolUsageStat
|
||||
{
|
||||
AgentId = g.Key.AgentId,
|
||||
Tool = g.Key.Tool,
|
||||
Calls = abgeschlossen.Count,
|
||||
Failed = abgeschlossen.Count(e => e.Status == AuditStatus.Error),
|
||||
Denied = abgeschlossen.Count(e => e.Status == AuditStatus.Denied),
|
||||
Unfinished = Math.Max(0, begonnen - abgeschlossen.Count),
|
||||
TotalDurationMs = abgeschlossen.Sum(e => e.DurationMs),
|
||||
MaxDurationMs = g.Max(e => e.DurationMs)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(s => s.Calls)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.FileRW;
|
||||
|
||||
/// <summary>
|
||||
/// Die Nachschau nach einem Absturz (#555) und das atomare Schreiben (#553).
|
||||
///
|
||||
/// Das Dateisystem wird auch hier nicht abstrahiert — es geht gerade darum, was auf der
|
||||
/// Platte steht, nachdem ein Aufruf unterbrochen wurde.
|
||||
/// </summary>
|
||||
public sealed class NachschauTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly FileRWTool _tool = new();
|
||||
|
||||
public NachschauTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace");
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private AgentToolContext Context() => new(
|
||||
"a1", "inst",
|
||||
new Dictionary<string, object?>(),
|
||||
new NullStateStore(),
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None,
|
||||
WorkspacePath: _root);
|
||||
|
||||
private Task<ProbeFinding> Probe(string json)
|
||||
=> _tool.ProbeAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
||||
|
||||
private Task<ToolResult> Run(string json)
|
||||
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
||||
|
||||
// ═══════════════════ #553: atomar schreiben ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Schreiben_hinterlaesst_keine_Nebendateien()
|
||||
{
|
||||
var result = await Run("""{"action":"write","path":"a.txt","content":"Inhalt"}""");
|
||||
result.Success.ShouldBeTrue();
|
||||
|
||||
// Das atomare Schreiben geht über eine Nebendatei — die muss danach weg sein.
|
||||
Directory.GetFiles(_root).ShouldHaveSingleItem();
|
||||
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("Inhalt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ueberschreiben_ersetzt_den_Inhalt_vollstaendig()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"ein sehr langer alter Inhalt"}""");
|
||||
await Run("""{"action":"write","path":"a.txt","content":"kurz"}""");
|
||||
|
||||
// Beim Ersetzen darf kein Rest der alten, längeren Fassung stehenbleiben.
|
||||
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("kurz");
|
||||
}
|
||||
|
||||
// ═══════════════════ #555: Nachschau bei write ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Fehlende_Datei_heisst_nichts_geschehen()
|
||||
{
|
||||
var befund = await Probe("""{"action":"write","path":"nie.txt","content":"x"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_beabsichtigte_Inhalt_heisst_durchgekommen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"fertig"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_alte_Inhalt_heisst_nicht_durchgekommen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"alt"}""");
|
||||
|
||||
// So sähe es aus, wenn der Absturz vor dem Ersetzen kam.
|
||||
var befund = await Probe("""{"action":"write","path":"a.txt","content":"neu"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
// ═══════════════════ #555: Nachschau bei append ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Vollstaendig_angehaengter_Text_wird_erkannt()
|
||||
{
|
||||
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
|
||||
await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_halb_angehaengter_Text_wird_als_Riss_erkannt()
|
||||
{
|
||||
// Anhängen ist nicht atomar — genau dieser Zustand kann wirklich entstehen.
|
||||
// Hier wird er von Hand hergestellt, weil ein echter Riss nicht reproduzierbar ist.
|
||||
var pfad = Path.Combine(_root, "log.txt");
|
||||
await File.WriteAllTextAsync(pfad, "Zeile 1\nZeile 2 ist nur halb ang");
|
||||
|
||||
var befund = await Probe(
|
||||
"""{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Partial);
|
||||
befund.Evidence.ShouldContain("von");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fehlt_der_Text_ganz_gilt_der_Aufruf_als_nicht_geschehen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
// ═══════════════════ Die Grenzen der Nachschau ═══════════════════
|
||||
|
||||
[Theory]
|
||||
[InlineData("read")]
|
||||
[InlineData("delete")]
|
||||
[InlineData("list")]
|
||||
public async Task Fuer_andere_Aktionen_wird_nichts_behauptet(string aktion)
|
||||
{
|
||||
var befund = await Probe($$"""{"action":"{{aktion}}","path":"a.txt"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_unbrauchbarer_Pfad_ergibt_Unbekannt_statt_einer_Ausnahme()
|
||||
{
|
||||
// Die Nachschau darf nie werfen: Ein Fehlschlag der Nachschau ist eine Aussage
|
||||
// über die Nachschau, nicht über die Welt.
|
||||
var befund = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullStateStore : IStateStore
|
||||
{
|
||||
public Task<string?> GetAsync(string key, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
Reference in New Issue
Block a user