3 Commits
Author SHA1 Message Date
RichardandClaude Opus 5 b568a45de3 Wiederaufnahme nach Absturz, Nachschau und Auswertung (#553-#556)
#553 FileRW schreibt atomar. Die Vorkehrung lag seit #241 im selben
Repository und wurde ueberall benutzt - Einstellungen, Sicherungen,
Chat-Zustand, Identity.md - nur der Weg, ueber den Agenten ihre
Arbeitsergebnisse ablegen, ging daran vorbei. Damit stand das Verhaeltnis
auf dem Kopf: geschuetzt war das Eigene, ungeschuetzt das Ergebnis.
append bleibt bewusst nicht atomar; das ginge nur, indem die ganze Datei
neu geschrieben wird, und bei einer grossen Protokolldatei waere das der
falsche Handel.

#554 Wiederaufnahme. Neuer Eintrag AuditStatus.Started, geschrieben VOR
der Wirkung und als einziger nicht "best effort" - auf ihn stuetzt sich
alles Weitere, also laeuft der Aufruf gar nicht erst, wenn er nicht
durchkommt. Neue Kennzeichnung IsRepeatable, getrennt von IsParallelSafe:
Das eine heisst "stoert keinen gleichzeitigen Aufruf", das andere
"richtet zweimal keinen Schaden an". CrashRecovery sichtet beim Start,
was offen blieb, und stuft ein - fuehrt aber nichts aus. Es sichtet und
berichtet; entscheiden soll der Aufrufer.

Dabei kippte eine alte Regel: Bisher wurde ein Abbruch bewusst NICHT
protokolliert. Das war richtig, solange es keinen Eintrag vor dem Aufruf
gab. Jetzt saehe sonst jeder Benutzerabbruch beim naechsten Start aus wie
ein Absturz - bis niemand mehr hinsieht. Deshalb AuditStatus.Cancelled.

#555 Optionale Zustandspruefung IToolStateProbe, umgesetzt in FileRW.
Unknown ist eine vollwertige Antwort, kein Fehlschlag: Ob ein SMTP-Server
angenommen hat, laesst sich nicht feststellen, und ein Werkzeug, das hier
raet, waere schlimmer als eines, das schweigt. Partial gilt ausdruecklich
nicht als geklaert - einen halb angehaengten Text sollte niemand
automatisch geraderuecken.

#556 Auswertung. ListForAgentAsync und SummarizeToolUsageAsync. Die Dauer
wird nur ueber abgeschlossene Aufrufe gemittelt; zaehlten die
Started-Eintraege mit ihrer Dauer 0 mit, saehe jedes Werkzeug doppelt so
schnell aus, wie es ist.

Nebenbei: SqliteStorage kann jetzt Spalten nachruesten. CREATE TABLE IF
NOT EXISTS allein genuegte nicht - bei einer bestehenden Datenbank tut die
Anweisung nichts, und erst das naechste INSERT waere gescheitert, im
Betrieb beim Anwender.

Vier Audit-Tests angepasst: Ein ausgefuehrter Aufruf erzeugt jetzt zwei
Eintraege statt einem. Ein Aufruf, der gar nicht erst laeuft - verweigert,
unbekannt, vorgelegt - hat weiterhin genau einen; der Beginn wird erst
nach Rechtepruefung und Staging geschrieben.

23 neue Tests, 912 gesamt, keine Regression. Build ohne Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:48:18 +02:00
RichardandClaude Opus 5 fb4b218e45 Abbruch hinterlaesst keinen unbeantworteten Werkzeugaufruf mehr (#552)
Wurde ein Chat abgebrochen, waehrend ein Werkzeug lief, blieb im Kontext
eine assistant-Nachricht mit tool_calls stehen, auf die keine Antwort
folgte. Diese Folge lehnt die Schnittstelle mit HTTP 400 ab - der Agent
war danach nicht mehr ansprechbar.

Der Schaden wurde dabei dauerhaft gemacht: Jeder catch-Block ruft
AddChatEntry, das ruft PersistChatState, und das schreibt den Kontext
atomar auf die Platte. Das atomare Schreiben war hier keine Rettung,
sondern die Garantie, dass die kaputte Fassung heil ankommt. Ein Neustart
half nicht, weil LoadPersistedChats sie zurueckholte.

Ausloeser war durchweg Normalbetrieb: AbortChat durch den Benutzer,
LoopGuard.Timeout, oder eine Instanz, die waehrend eines Werkzeuglaufs
geordnet herunterfaehrt.

Die Antworten gehen jetzt aus einem finally raus. Auf jedem Ausgangspfad
bekommt jeder Aufruf seine Antwort, bei Abbruch eine Ersatzantwort, die
auch sagt, was passiert ist - eine leere waere formal gueltig, liesse das
Modell aber raetseln.

Zwei Tests: einer auf die Form des Kontexts, einer auf das, worauf es
ankommt - dass ein zweiter Chat danach wieder durchlaeuft. Der erste
schlug vor der Behebung fehl.

543 Tests in Core, keine Regression. Build ohne Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:39:43 +02:00
RichardandClaude Opus 5 ea3844006f Harness gegen stillen Verlust gehaertet (#547-#551)
Fuenf Befunde mit einem gemeinsamen Fehlermodus: Keiner erzeugt eine
Fehlermeldung. Der Lauf wird fertig, das Ergebnis sieht plausibel aus,
und nur die Qualitaet ist schlechter.

#547 Der Begleittext zum Werkzeugaufruf ging verloren. AssistantWithToolCalls
baute eine neue Nachricht aus nur den Aufrufen; der Text, den das Modell
daneben geschrieben hatte, fiel weg. Damit verlor das Modell zwischen zwei
Schritten seinen eigenen Plan, und im Audit stand, dass ein Werkzeug lief,
nicht warum.

#548 finish_reason wurde nirgends gelesen. Eine an der Ausgabegrenze
abgeschnittene Antwort galt als fertiges Ergebnis. Das trifft besonders
Laeufe im Hintergrund, wo niemand zusieht. Neuer Status Truncated: Das
Teilergebnis geht weiter, aber gekennzeichnet.

#549 reasoning_details wurde verworfen. Bei Werkzeugnutzung muss der
Denkverlauf unveraendert zurueck, sonst beginnt ein Thinking-Modell jedes
Mal von vorn. Wird jetzt als rohes JSON durchgereicht - ohne Auslegung,
weil Reihenfolge und Signatur daran haengen.

#550 Die Anfrage verlangt jetzt einen Anbieter, der alle mitgeschickten
Parameter beherrscht. Ohne das durfte ein Anbieter cache_control, reasoning
oder sogar tools stillschweigend uebergehen - der Lauf lieferte dann eine
gewoehnliche Textantwort, wo ein Werkzeugaufruf hingehoert haette. Dazu
usage.include: der abgerechnete Betrag schlaegt die Eigenrechnung, die als
Rueckfallebene bleibt. Denktiefe je Agent einstellbar, Vorgabe off.

#551 Werkzeuge eines Zuges laufen gleichzeitig, sofern sie sich dafuer
freigeben. Die Vorgabe von IsParallelSafe ist false, weil die falsche
Antwort hier nicht auffaellt: Zwei schreibende Aufrufe auf derselben Datei
scheitern nicht zuverlaessig, sondern gelegentlich. Freigegeben ist bisher
nur WebFetch - es liest ausschliesslich. Die Reihenfolge der Ergebnisse
folgt den Aufrufen, nicht der Fertigstellung; daran haengen die
tool_call_id-Zuordnung und der Cache-Praefix.

27 neue Tests, 887 gesamt, keine Regression. Build ohne Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:34:36 +02:00
23 changed files with 2284 additions and 72 deletions
+63 -2
View File
@@ -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
+62 -1
View File
@@ -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
+192
View File
@@ -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>
+284 -45
View File
@@ -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,16 +1476,16 @@ 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;
try
{
await _auditRepository.AppendAsync(new AuditEntry
var entry = new AuditEntry
{
RunId = runId,
CallId = callId,
AgentId = agentConfig.AgentId,
Model = agentConfig.Model,
Source = AuditSource.Normalize(source),
@@ -1284,7 +1495,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Summary = Cap(summary, 500),
DurationMs = durationMs,
OccurredAt = DateTime.Now
}, CancellationToken.None);
};
// Der "angefangen"-Eintrag ist der einzige, dessen Fehlschlag zählt (#554):
// Auf ihn stützt sich die Wiederaufnahme nach einem Absturz. Kommt er nicht
// durch, liefe der Aufruf ohne jede Spur — dann lieber gar nicht erst laufen
// lassen. Alle übrigen Einträge bleiben "best effort": Ein Protokollfehler soll
// einen bereits erfolgreichen Aufruf nicht nachträglich zum Scheitern bringen.
if (status == AuditStatus.Started)
{
await _auditRepository.AppendAsync(entry, CancellationToken.None);
return;
}
try
{
await _auditRepository.AppendAsync(entry, CancellationToken.None);
}
catch (Exception ex)
{
@@ -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 -1
View File
@@ -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
}
+54 -1
View File
@@ -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();
}
}
+34
View File
@@ -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);
}
+94 -2
View File
@@ -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;
}