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>
This commit is contained in:
@@ -3,6 +3,16 @@ namespace ClawdDotNet.Core.Audit;
|
||||
/// <summary>Ausgang eines Tool-Aufrufs, von der Engine festgestellt.</summary>
|
||||
public enum AuditStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Der Aufruf beginnt jetzt (#554). Der einzige Eintrag, der <b>vor</b> der Wirkung
|
||||
/// geschrieben wird — und der einzige, der deshalb einen harten Absturz überlebt.
|
||||
///
|
||||
/// <para>Bleibt er ohne Abschluss, lief der Aufruf, als die Anwendung starb. Was er
|
||||
/// dabei bewirkt hat, sagt er nicht: Der Absturz kann vor oder nach der Wirkung
|
||||
/// liegen. Er grenzt die Frage ein, er beantwortet sie nicht.</para>
|
||||
/// </summary>
|
||||
Started,
|
||||
|
||||
/// <summary>Tool lief und lieferte ein Ergebnis.</summary>
|
||||
Ok,
|
||||
|
||||
@@ -16,7 +26,18 @@ public enum AuditStatus
|
||||
NotFound,
|
||||
|
||||
/// <summary>Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt.</summary>
|
||||
Staged
|
||||
Staged,
|
||||
|
||||
/// <summary>
|
||||
/// Der Aufruf wurde unterbrochen, während er lief (#554) — Benutzerabbruch,
|
||||
/// Zeitüberschreitung oder geordnetes Ende der Instanz.
|
||||
///
|
||||
/// <para>Wie beim Absturz ist die Wirkung ungewiss. Der Unterschied ist, dass wir
|
||||
/// hier <b>wissen</b>, dass unterbrochen wurde. Ohne diesen Eintrag bliebe der
|
||||
/// <see cref="Started"/>-Eintrag offen, und jeder gewöhnliche Abbruch sähe beim
|
||||
/// nächsten Start aus wie ein Absturz — bis niemand mehr hinsieht.</para>
|
||||
/// </summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -34,6 +55,15 @@ public sealed record AuditEntry
|
||||
/// <summary>Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs.</summary>
|
||||
public string RunId { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Id des einzelnen Werkzeugaufrufs (die <c>tool_call_id</c> des Modells). Paart
|
||||
/// den <see cref="AuditStatus.Started"/>-Eintrag mit seinem Abschluss (#554).
|
||||
///
|
||||
/// <para>Leer bei Einträgen aus der Zeit vor #554 und bei solchen, die keinem
|
||||
/// einzelnen Aufruf zuzuordnen sind.</para>
|
||||
/// </summary>
|
||||
public string CallId { get; init; } = "";
|
||||
|
||||
public string AgentId { get; init; } = "";
|
||||
|
||||
/// <summary>Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source).</summary>
|
||||
@@ -93,6 +123,37 @@ public sealed record RunReceipt
|
||||
public DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kennzahlen eines Werkzeugs bei einem Agenten (#556). Aus dem Audit-Log gerechnet,
|
||||
/// ohne eine einzige zusätzliche Messung.
|
||||
/// </summary>
|
||||
public sealed record ToolUsageStat
|
||||
{
|
||||
public string AgentId { get; init; } = "";
|
||||
public string Tool { get; init; } = "";
|
||||
|
||||
/// <summary>Abgeschlossene Aufrufe — <see cref="AuditStatus.Started"/> zählt nicht mit.</summary>
|
||||
public int Calls { get; init; }
|
||||
|
||||
public int Failed { get; init; }
|
||||
public int Denied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aufrufe, die begannen und nie endeten. Nicht dasselbe wie gescheitert: Hier fehlt
|
||||
/// die Auskunft, statt dass sie negativ wäre.
|
||||
/// </summary>
|
||||
public int Unfinished { get; init; }
|
||||
|
||||
public long TotalDurationMs { get; init; }
|
||||
public long MaxDurationMs { get; init; }
|
||||
|
||||
/// <summary>Mittlere Dauer. 0, wenn nichts abgeschlossen wurde.</summary>
|
||||
public double AverageDurationMs => Calls == 0 ? 0 : (double)TotalDurationMs / Calls;
|
||||
|
||||
/// <summary>Anteil der gescheiterten an den abgeschlossenen Aufrufen, 0 bis 1.</summary>
|
||||
public double FailureRate => Calls == 0 ? 0 : (double)Failed / Calls;
|
||||
}
|
||||
|
||||
/// <summary>Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit
|
||||
/// <c>ChatSource</c>; <see cref="Unknown"/> steht für „nicht bekannt", nicht für geraten.</summary>
|
||||
public static class AuditSource
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Wie mit einem Aufruf zu verfahren ist, der beim Absturz lief.
|
||||
/// </summary>
|
||||
public enum RecoveryVerdict
|
||||
{
|
||||
/// <summary>
|
||||
/// Das Werkzeug gibt sich als wiederholbar aus. Ein zweiter, gleicher Aufruf
|
||||
/// führt zum selben Zustand — ob der erste wirkte, muss deshalb niemand wissen.
|
||||
/// </summary>
|
||||
Repeatable,
|
||||
|
||||
/// <summary>
|
||||
/// Die Wirkung ist ungewiss und eine Wiederholung nicht unbedenklich. Gehört einem
|
||||
/// Menschen vorgelegt, nicht automatisch entschieden.
|
||||
/// </summary>
|
||||
NeedsReview,
|
||||
|
||||
/// <summary>
|
||||
/// Das Werkzeug gibt es nicht mehr — umbenannt, entfernt, oder dem Agenten
|
||||
/// entzogen. Wiederholen ginge gar nicht; hier bleibt nur der Hinweis.
|
||||
/// </summary>
|
||||
ToolUnknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Aufruf, der beim Absturz lief, mit seiner Einstufung — und, falls das Werkzeug
|
||||
/// nachsehen konnte, mit dem Befund der Nachschau (#555).
|
||||
/// </summary>
|
||||
public sealed record RecoveryCase(
|
||||
AuditEntry Entry,
|
||||
RecoveryVerdict Verdict,
|
||||
string Reason,
|
||||
ProbeFinding? Probe = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Ob dieser Fall ohne menschliche Entscheidung erledigt ist.
|
||||
///
|
||||
/// <para>Zwei Wege führen dahin: Das Werkzeug ist wiederholbar, oder die Nachschau
|
||||
/// hat die Ungewissheit aufgelöst. Ein <see cref="ProbeOutcome.Complete"/> heißt,
|
||||
/// die Wirkung ist eingetreten — nichts zu tun. Ein <see cref="ProbeOutcome.NotStarted"/>
|
||||
/// heißt, sie ist es nicht — der Aufruf kann gefahrlos wiederholt werden.</para>
|
||||
///
|
||||
/// <para><see cref="ProbeOutcome.Partial"/> gehört ausdrücklich <b>nicht</b> dazu.
|
||||
/// Ein halb angehängter Text ist genau der Fall, den niemand automatisch
|
||||
/// geraderücken sollte.</para>
|
||||
/// </summary>
|
||||
public bool Erledigt =>
|
||||
Verdict == RecoveryVerdict.Repeatable ||
|
||||
Probe?.Outcome is ProbeOutcome.Complete or ProbeOutcome.NotStarted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sichtet nach dem Start, was beim letzten Mal offen geblieben ist (#554).
|
||||
///
|
||||
/// <para><b>Was dieser Dienst kann und was nicht:</b> Er findet Aufrufe, die begonnen
|
||||
/// und nie geendet haben, und stuft sie ein. Er kann <i>nicht</i> sagen, ob ihre Wirkung
|
||||
/// eingetreten ist — der Absturz kann vor oder nach dem Versenden einer Mail liegen, und
|
||||
/// im Protokoll steht beides Mal dasselbe. Er verkleinert die Frage von „was ist in zehn
|
||||
/// Minuten passiert?" auf „diese zwei Aufrufe waren in der Luft". Mehr nicht, und das
|
||||
/// absichtlich: Ein Dienst, der hier eine Gewissheit vortäuschte, führte zu doppelt
|
||||
/// versendeten Mails.</para>
|
||||
///
|
||||
/// <para>Er führt deshalb auch nichts aus. Er sichtet und berichtet; was geschieht,
|
||||
/// entscheidet der Aufrufer.</para>
|
||||
/// </summary>
|
||||
public sealed class CrashRecovery
|
||||
{
|
||||
private readonly IAuditRepository _audit;
|
||||
private readonly ToolRegistry _tools;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Baut den Kontext, den eine Nachschau braucht — Arbeitsverzeichnisse, Einstellungen.
|
||||
/// Null heißt: keine Nachschau, nur Einstufung. Die Engine kennt diese Angaben, der
|
||||
/// Wiederaufnahmedienst nicht; deshalb werden sie hereingereicht statt nachgebaut.
|
||||
/// </summary>
|
||||
private readonly Func<string, string, AgentToolContext?>? _kontextFuer;
|
||||
|
||||
public CrashRecovery(
|
||||
IAuditRepository audit,
|
||||
ToolRegistry tools,
|
||||
ILoggerFactory loggerFactory,
|
||||
Func<string, string, AgentToolContext?>? kontextFuer = null)
|
||||
{
|
||||
_audit = audit;
|
||||
_tools = tools;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Audit.CrashRecovery");
|
||||
_kontextFuer = kontextFuer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sichtet die offenen Aufrufe. Leere Liste heißt: Das letzte Ende war geordnet.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RecoveryCase>> SichtenAsync(CancellationToken ct)
|
||||
{
|
||||
var offen = await _audit.ListOpenCallsAsync(ct);
|
||||
|
||||
if (offen.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Keine offenen Werkzeugaufrufe — das letzte Ende war geordnet.");
|
||||
return [];
|
||||
}
|
||||
|
||||
var faelle = new List<RecoveryCase>();
|
||||
foreach (var eintrag in offen)
|
||||
faelle.Add(await NachsehenAsync(Einstufen(eintrag), ct));
|
||||
|
||||
var zurPruefung = faelle.Count(f => !f.Erledigt);
|
||||
|
||||
_logger.LogWarning(
|
||||
"{Gesamt} Werkzeugaufruf(e) liefen beim letzten Ende und wurden nie abgeschlossen. " +
|
||||
"{Erledigt} davon sind geklärt, {Pruefung} brauchen eine Entscheidung.",
|
||||
faelle.Count, faelle.Count - zurPruefung, zurPruefung);
|
||||
|
||||
foreach (var fall in faelle)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Offen: {Tool} von {Agent} (Lauf {RunId}, begonnen {Zeit:yyyy-MM-dd HH:mm:ss}) — " +
|
||||
"{Urteil}: {Grund}{Nachschau}",
|
||||
fall.Entry.Tool, fall.Entry.AgentId, fall.Entry.RunId,
|
||||
fall.Entry.OccurredAt, fall.Verdict, fall.Reason,
|
||||
fall.Probe is { } p ? $" Nachschau: {p.Outcome} — {p.Evidence}" : "");
|
||||
}
|
||||
|
||||
return faelle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt das Werkzeug, was von dem Aufruf übrig ist — sofern es das beantworten kann
|
||||
/// (#555). Ein Werkzeug ohne Nachschau ist der Normalfall, kein Mangel.
|
||||
/// </summary>
|
||||
private async Task<RecoveryCase> NachsehenAsync(RecoveryCase fall, CancellationToken ct)
|
||||
{
|
||||
if (_tools.Get(fall.Entry.Tool) is not IToolStateProbe probe || _kontextFuer is null)
|
||||
return fall;
|
||||
|
||||
var kontext = _kontextFuer(fall.Entry.AgentId, fall.Entry.Tool);
|
||||
if (kontext is null)
|
||||
return fall;
|
||||
|
||||
try
|
||||
{
|
||||
// Die Argumente werden gekappt gespeichert. Ein abgeschnittener Eintrag ist
|
||||
// kein gültiges JSON — dann gibt es nichts nachzusehen, und das ist eine
|
||||
// Aussage über unser Protokoll, nicht über die Welt.
|
||||
using var argumente = JsonDocument.Parse(fall.Entry.Arguments);
|
||||
|
||||
var befund = await probe.ProbeAsync(argumente.RootElement, kontext, ct);
|
||||
return fall with { Probe = befund };
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return fall with
|
||||
{
|
||||
Probe = ProbeFinding.Unknown(
|
||||
"Die protokollierten Argumente sind unvollständig (gekappt) — " +
|
||||
"der Aufruf lässt sich nicht nachstellen.")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Nachschau für {Tool} scheiterte.", fall.Entry.Tool);
|
||||
return fall with { Probe = ProbeFinding.Unknown($"Nachschau scheiterte: {ex.Message}") };
|
||||
}
|
||||
}
|
||||
|
||||
private RecoveryCase Einstufen(AuditEntry entry)
|
||||
{
|
||||
var tool = _tools.Get(entry.Tool);
|
||||
|
||||
if (tool is null)
|
||||
{
|
||||
return new RecoveryCase(entry, RecoveryVerdict.ToolUnknown,
|
||||
$"Werkzeug '{entry.Tool}' ist nicht mehr registriert.");
|
||||
}
|
||||
|
||||
if (tool.IsRepeatable)
|
||||
{
|
||||
return new RecoveryCase(entry, RecoveryVerdict.Repeatable,
|
||||
"Das Werkzeug gibt sich als wiederholbar aus — ein zweiter Aufruf führt zum selben Zustand.");
|
||||
}
|
||||
|
||||
return new RecoveryCase(entry, RecoveryVerdict.NeedsReview,
|
||||
"Wirkung ungewiss und Wiederholung nicht als unbedenklich gekennzeichnet.");
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,25 @@ public interface IAuditRepository
|
||||
|
||||
/// <summary>Zahl der Log-Einträge insgesamt.</summary>
|
||||
Task<int> CountAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Aufrufe, die begonnen haben und nie abgeschlossen wurden (#554) — also
|
||||
/// <see cref="AuditStatus.Started"/> ohne späteren Eintrag mit derselben
|
||||
/// <see cref="AuditEntry.CallId"/>.
|
||||
///
|
||||
/// <para>Nach einem geordneten Ende ist die Liste leer. Ist sie es nicht, ist die
|
||||
/// Anwendung gestorben, während diese Aufrufe liefen. Was sie bewirkt haben, sagt
|
||||
/// die Liste nicht — das ist die Frage, die danach zu klären ist.</para>
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Die jüngsten Aufrufe eines Agenten, optional ab einem Zeitpunkt (#556).</summary>
|
||||
Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
|
||||
string agentId, DateTime? seit, int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Kennzahlen je Agent und Werkzeug (#556): wie oft, wie oft vergeblich, wie lange.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
|
||||
string? agentId, DateTime? seit, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ public sealed class SqliteAuditRepository : IAuditRepository
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO AuditLog
|
||||
(RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
|
||||
(RunId, CallId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
|
||||
VALUES
|
||||
(@runId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
|
||||
(@runId, @callId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@runId", entry.RunId);
|
||||
cmd.Parameters.AddWithValue("@callId", entry.CallId);
|
||||
cmd.Parameters.AddWithValue("@agentId", entry.AgentId);
|
||||
cmd.Parameters.AddWithValue("@model", entry.Model);
|
||||
cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source));
|
||||
@@ -120,10 +121,113 @@ public sealed class SqliteAuditRepository : IAuditRepository
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offene Aufrufe (#554): ein <c>Started</c>, zu dem kein späterer Eintrag mit
|
||||
/// derselben CallId existiert.
|
||||
///
|
||||
/// <para>Einträge ohne CallId bleiben außen vor — sie stammen aus der Zeit vor #554
|
||||
/// und ließen sich nicht paaren. Sie alle als offen zu melden, wäre schlimmer als
|
||||
/// sie zu übergehen: Der erste Start nach der Aktualisierung legte sonst das ganze
|
||||
/// Altprotokoll zur Prüfung vor.</para>
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"""
|
||||
SELECT {AuditColumns} FROM AuditLog a
|
||||
WHERE a.Status = 'Started'
|
||||
AND a.CallId <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM AuditLog b
|
||||
WHERE b.CallId = a.CallId
|
||||
AND b.Id > a.Id
|
||||
AND b.Status <> 'Started'
|
||||
)
|
||||
ORDER BY a.Id
|
||||
""";
|
||||
return await ReadEntriesAsync(cmd, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
|
||||
string agentId, DateTime? seit, int limit, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"""
|
||||
SELECT {AuditColumns} FROM AuditLog
|
||||
WHERE AgentId = @agentId
|
||||
AND (@seit IS NULL OR OccurredAt >= @seit)
|
||||
ORDER BY Id DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@agentId", agentId);
|
||||
cmd.Parameters.AddWithValue("@seit", seit is { } s ? Format(s) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@limit", limit);
|
||||
return await ReadEntriesAsync(cmd, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kennzahlen je Agent und Werkzeug (#556).
|
||||
///
|
||||
/// <para>Die Dauer wird nur über abgeschlossene Aufrufe gemittelt. Ein
|
||||
/// <c>Started</c>-Eintrag trägt die Dauer 0, weil zum Zeitpunkt des Schreibens noch
|
||||
/// nichts gelaufen war — würde er mitgezählt, sähe jedes Werkzeug doppelt so schnell
|
||||
/// aus, wie es ist.</para>
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
|
||||
string? agentId, DateTime? seit, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT AgentId, Tool,
|
||||
SUM(CASE WHEN Status <> 'Started' THEN 1 ELSE 0 END) AS Calls,
|
||||
SUM(CASE WHEN Status = 'Error' THEN 1 ELSE 0 END) AS Failed,
|
||||
SUM(CASE WHEN Status = 'Denied' THEN 1 ELSE 0 END) AS Denied,
|
||||
SUM(CASE WHEN Status = 'Started' THEN 1 ELSE 0 END) AS Begonnen,
|
||||
SUM(CASE WHEN Status <> 'Started' THEN DurationMs ELSE 0 END) AS GesamtMs,
|
||||
MAX(DurationMs) AS MaxMs
|
||||
FROM AuditLog
|
||||
WHERE (@agentId IS NULL OR AgentId = @agentId)
|
||||
AND (@seit IS NULL OR OccurredAt >= @seit)
|
||||
GROUP BY AgentId, Tool
|
||||
ORDER BY Calls DESC
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@agentId", (object?)agentId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@seit", seit is { } s ? Format(s) : DBNull.Value);
|
||||
|
||||
var results = new List<ToolUsageStat>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var abgeschlossen = reader.GetInt32(2);
|
||||
var begonnen = reader.GetInt32(5);
|
||||
|
||||
results.Add(new ToolUsageStat
|
||||
{
|
||||
AgentId = reader.GetString(0),
|
||||
Tool = reader.GetString(1),
|
||||
Calls = abgeschlossen,
|
||||
Failed = reader.GetInt32(3),
|
||||
Denied = reader.GetInt32(4),
|
||||
|
||||
// Begonnene minus abgeschlossene: Was übrig bleibt, hat nie geendet.
|
||||
// Nie negativ — ein Abschluss ohne Start kann es nicht geben, aber
|
||||
// Alteinträge ohne Started-Zeile gibt es sehr wohl.
|
||||
Unfinished = Math.Max(0, begonnen - abgeschlossen),
|
||||
|
||||
TotalDurationMs = reader.GetInt64(6),
|
||||
MaxDurationMs = reader.GetInt64(7)
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string AuditColumns =
|
||||
"Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt";
|
||||
"Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt, CallId";
|
||||
|
||||
private const string ReceiptColumns =
|
||||
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
|
||||
@@ -150,7 +254,8 @@ public sealed class SqliteAuditRepository : IAuditRepository
|
||||
Status = Enum.TryParse<AuditStatus>(r.GetString(7), out var s) ? s : AuditStatus.Ok,
|
||||
Summary = r.GetString(8),
|
||||
DurationMs = r.GetInt64(9),
|
||||
OccurredAt = Parse(r.GetString(10))
|
||||
OccurredAt = Parse(r.GetString(10)),
|
||||
CallId = r.GetString(11)
|
||||
};
|
||||
|
||||
private static RunReceipt ReadReceipt(SqliteDataReader r) => new()
|
||||
|
||||
@@ -1230,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
|
||||
{
|
||||
@@ -1271,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);
|
||||
@@ -1301,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)
|
||||
@@ -1457,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),
|
||||
@@ -1476,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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,25 @@ public interface IAgentTool
|
||||
/// </summary>
|
||||
bool IsParallelSafe => false;
|
||||
|
||||
/// <summary>
|
||||
/// Ob ein zweiter, gleicher Aufruf schadlos bleibt (#554).
|
||||
///
|
||||
/// <para>Gebraucht wird das nach einem harten Absturz: Im Protokoll steht, dass ein
|
||||
/// Aufruf begann, nicht ob er wirkte. Bei einem wiederholbaren Werkzeug muss man das
|
||||
/// auch nicht wissen — man ruft es einfach noch einmal. Bei allen anderen bleibt nur,
|
||||
/// den Fall vorzulegen statt zu raten.</para>
|
||||
///
|
||||
/// <para>Nicht zu verwechseln mit <see cref="IsParallelSafe"/>. Das heißt „stört
|
||||
/// keinen gleichzeitigen Aufruf", dies hier heißt „richtet zweimal keinen Schaden an".
|
||||
/// Ein Werkzeug kann das eine sein und das andere nicht: Eine Datei zu überschreiben
|
||||
/// ist wiederholbar — zweimal derselbe Inhalt ergibt denselben Zustand —, verträgt
|
||||
/// aber keinen zweiten Schreiber zur selben Zeit.</para>
|
||||
///
|
||||
/// <para>Vorgabe <c>false</c>, aus demselben Grund wie oben: Wer sich irrt, versendet
|
||||
/// die Mail zweimal.</para>
|
||||
/// </summary>
|
||||
bool IsRepeatable => false;
|
||||
|
||||
Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Tools;
|
||||
|
||||
/// <summary>Was die Nachschau über einen unterbrochenen Aufruf ergeben hat.</summary>
|
||||
public enum ProbeOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Nicht feststellbar — und das ist eine vollwertige Antwort, kein Fehlschlag.
|
||||
///
|
||||
/// <para>Die meisten Werkzeuge landen hier, und zwar zu Recht: Ob ein SMTP-Server
|
||||
/// eine Nachricht angenommen hat, lässt sich im Nachhinein nicht feststellen, wenn
|
||||
/// die Gegenseite keine Kennung führt. Ein Werkzeug, das in so einem Fall
|
||||
/// „vermutlich nicht" meldete, wäre schlimmer als eines, das schweigt.</para>
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Nachweislich nichts geschehen — der Aufruf kam nicht bis zur Wirkung.</summary>
|
||||
NotStarted,
|
||||
|
||||
/// <summary>Die Wirkung ist eingetreten, aber unvollständig.</summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>Die Wirkung ist vollständig eingetreten. Wiederholen wäre überflüssig.</summary>
|
||||
Complete
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Befund der Nachschau. <paramref name="Evidence"/> sagt, <b>woran</b> es erkannt
|
||||
/// wurde — der Befund ist Beleg, nicht Urteil, und wer ihn liest, soll ihn beurteilen
|
||||
/// können statt ihm glauben zu müssen.
|
||||
/// </summary>
|
||||
public sealed record ProbeFinding(ProbeOutcome Outcome, string Evidence)
|
||||
{
|
||||
public static ProbeFinding Unknown(string grund) => new(ProbeOutcome.Unknown, grund);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional von einem Werkzeug zu bedienen (#555): Nach einem Absturz die Welt befragen,
|
||||
/// wie weit ein unterbrochener Aufruf gekommen ist.
|
||||
///
|
||||
/// <para>Das Protokoll sagt, dass ein Aufruf begann, nicht ob er wirkte. Manche Werkzeuge
|
||||
/// können diese Lücke selbst schließen, weil ihr Wirkungsort befragbar ist — ein
|
||||
/// Dateisystem ist es, ein fremder Mailserver nicht. Deshalb ist die Schnittstelle
|
||||
/// optional, und ihr Fehlen ist eine Aussage: Bei diesem Werkzeug bleibt es ungewiss.</para>
|
||||
///
|
||||
/// <para><b>Was hier nicht hingehört:</b> die Frage, ob ein Inhalt „mitten im Satz"
|
||||
/// endet, weil das Modell an seine Ausgabegrenze stieß. Die Datei ist dann fehlerfrei
|
||||
/// geschrieben — nur der Inhalt ist halb. Vom Wirkungsort aus lässt sich eine kurze
|
||||
/// Notiz nicht von einem abgeschnittenen Bericht unterscheiden. Diese Auskunft gibt es
|
||||
/// zuverlässig an der Quelle, über <c>finish_reason</c> (#548).</para>
|
||||
/// </summary>
|
||||
public interface IToolStateProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Sieht nach, was der Aufruf mit diesen Argumenten hinterlassen hat.
|
||||
///
|
||||
/// <para>Darf nichts verändern. Wirft nicht: Was sich nicht feststellen lässt, wird
|
||||
/// als <see cref="ProbeOutcome.Unknown"/> gemeldet, mit Begründung.</para>
|
||||
/// </summary>
|
||||
Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.FileRW;
|
||||
|
||||
public sealed class FileRWTool : IAgentTool
|
||||
public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten
|
||||
@@ -200,6 +201,88 @@ public sealed class FileRWTool : IAgentTool
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nachschau nach einem Absturz (#555). Sieht nach, was von einem unterbrochenen
|
||||
/// Aufruf übrig ist — verändert nichts.
|
||||
///
|
||||
/// <para>Wie weit die Auskunft trägt, hängt an der Aktion:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>write</b> — seit #553 atomar. Entweder die alte oder die neue Fassung,
|
||||
/// nie eine halbe. Ein Vergleich mit dem beabsichtigten Inhalt sagt deshalb
|
||||
/// zuverlässig, ob der Aufruf durchkam.</item>
|
||||
/// <item><b>append</b> — nicht atomar und nicht atomar zu machen. Hier kann wirklich
|
||||
/// ein Riss entstehen, und hier lohnt die Nachschau: Steht der beabsichtigte Text
|
||||
/// vollständig am Ende, kam der Aufruf durch; steht ein Anfang davon da, riss es
|
||||
/// mittendrin.</item>
|
||||
/// <item>alles andere — <see cref="ProbeOutcome.Unknown"/>. Lieber schweigen als
|
||||
/// raten.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public async Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = arguments.TryGetProperty("action", out var a) ? a.GetString() : null;
|
||||
|
||||
if (action is not ("write" or "append"))
|
||||
return ProbeFinding.Unknown($"Für die Aktion '{action ?? "?"}' gibt es nichts nachzusehen.");
|
||||
|
||||
var workspace = (arguments.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
|
||||
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
return ProbeFinding.Unknown("Workspace-Pfad ist nicht bekannt.");
|
||||
|
||||
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
|
||||
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Die Datei existiert nicht — geschrieben wurde nichts.");
|
||||
}
|
||||
|
||||
var vorhanden = await File.ReadAllTextAsync(path, ct);
|
||||
|
||||
if (action == "write")
|
||||
{
|
||||
return vorhanden == erwartet
|
||||
? new ProbeFinding(ProbeOutcome.Complete,
|
||||
$"Der Inhalt entspricht genau dem beabsichtigten ({erwartet.Length} Zeichen).")
|
||||
: new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Die Datei trägt noch den alten Inhalt. Seit #553 wird atomar " +
|
||||
"geschrieben — eine halbe Fassung kann es nicht geben.");
|
||||
}
|
||||
|
||||
// append
|
||||
if (vorhanden.EndsWith(erwartet, StringComparison.Ordinal))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.Complete,
|
||||
$"Der beabsichtigte Text steht vollständig am Ende ({erwartet.Length} Zeichen).");
|
||||
}
|
||||
|
||||
// Wie viel vom Erwarteten steht am Ende? Das ist die Rissstelle.
|
||||
for (var laenge = Math.Min(erwartet.Length - 1, vorhanden.Length); laenge > 0; laenge--)
|
||||
{
|
||||
if (vorhanden.EndsWith(erwartet[..laenge], StringComparison.Ordinal))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.Partial,
|
||||
$"Angehängt wurden {laenge} von {erwartet.Length} Zeichen — " +
|
||||
"der Aufruf riss mittendrin ab.");
|
||||
}
|
||||
}
|
||||
|
||||
return new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Vom beabsichtigten Text steht nichts am Ende der Datei.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Nie werfen: Eine gescheiterte Nachschau ist keine Aussage über die Welt,
|
||||
// sondern eine über die Nachschau.
|
||||
return ProbeFinding.Unknown($"Nachschau nicht möglich: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context)
|
||||
{
|
||||
var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : "";
|
||||
@@ -268,7 +351,12 @@ public sealed class FileRWTool : IAgentTool
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
// Atomar schreiben (#553): erst in eine Nebendatei, dann ersetzen. Ein Absturz
|
||||
// mitten im Vorgang hinterlässt damit entweder die alte oder die neue Fassung,
|
||||
// nie eine halbe. Zuvor stand hier File.WriteAllTextAsync — die Anwendung
|
||||
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
|
||||
// Agenten aber nicht.
|
||||
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Datei erfolgreich geschrieben: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
finally
|
||||
@@ -286,6 +374,10 @@ public sealed class FileRWTool : IAgentTool
|
||||
await fileLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Bewusst NICHT atomar (#553): Anhängen ließe sich nur atomar machen, indem
|
||||
// die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre
|
||||
// das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es
|
||||
// die Zustandsprüfung in #555.
|
||||
await File.AppendAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ public sealed class WebFetchTool : IAgentTool
|
||||
/// </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"));
|
||||
}
|
||||
@@ -37,4 +37,58 @@ internal sealed class InMemoryAuditRepository : IAuditRepository
|
||||
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList());
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
|
||||
|
||||
/// <summary>Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId.</summary>
|
||||
public Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct)
|
||||
{
|
||||
var alle = Entries.ToList();
|
||||
|
||||
var abgeschlossen = alle
|
||||
.Where(e => e.Status != AuditStatus.Started && e.CallId.Length > 0)
|
||||
.Select(e => e.CallId)
|
||||
.ToHashSet();
|
||||
|
||||
var offen = alle
|
||||
.Where(e => e.Status == AuditStatus.Started
|
||||
&& e.CallId.Length > 0
|
||||
&& !abgeschlossen.Contains(e.CallId))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEntry>>(offen);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
|
||||
string agentId, DateTime? seit, int limit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<AuditEntry>>(
|
||||
Entries.Where(e => e.AgentId == agentId)
|
||||
.Where(e => seit is null || e.OccurredAt >= seit)
|
||||
.Reverse()
|
||||
.Take(limit)
|
||||
.ToList());
|
||||
|
||||
public Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
|
||||
string? agentId, DateTime? seit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<ToolUsageStat>>(
|
||||
Entries.Where(e => agentId is null || e.AgentId == agentId)
|
||||
.Where(e => seit is null || e.OccurredAt >= seit)
|
||||
.GroupBy(e => (e.AgentId, e.Tool))
|
||||
.Select(g =>
|
||||
{
|
||||
var abgeschlossen = g.Where(e => e.Status != AuditStatus.Started).ToList();
|
||||
var begonnen = g.Count(e => e.Status == AuditStatus.Started);
|
||||
|
||||
return new ToolUsageStat
|
||||
{
|
||||
AgentId = g.Key.AgentId,
|
||||
Tool = g.Key.Tool,
|
||||
Calls = abgeschlossen.Count,
|
||||
Failed = abgeschlossen.Count(e => e.Status == AuditStatus.Error),
|
||||
Denied = abgeschlossen.Count(e => e.Status == AuditStatus.Denied),
|
||||
Unfinished = Math.Max(0, begonnen - abgeschlossen.Count),
|
||||
TotalDurationMs = abgeschlossen.Sum(e => e.DurationMs),
|
||||
MaxDurationMs = g.Max(e => e.DurationMs)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(s => s.Calls)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.FileRW;
|
||||
|
||||
/// <summary>
|
||||
/// Die Nachschau nach einem Absturz (#555) und das atomare Schreiben (#553).
|
||||
///
|
||||
/// Das Dateisystem wird auch hier nicht abstrahiert — es geht gerade darum, was auf der
|
||||
/// Platte steht, nachdem ein Aufruf unterbrochen wurde.
|
||||
/// </summary>
|
||||
public sealed class NachschauTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly FileRWTool _tool = new();
|
||||
|
||||
public NachschauTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace");
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private AgentToolContext Context() => new(
|
||||
"a1", "inst",
|
||||
new Dictionary<string, object?>(),
|
||||
new NullStateStore(),
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None,
|
||||
WorkspacePath: _root);
|
||||
|
||||
private Task<ProbeFinding> Probe(string json)
|
||||
=> _tool.ProbeAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
||||
|
||||
private Task<ToolResult> Run(string json)
|
||||
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
||||
|
||||
// ═══════════════════ #553: atomar schreiben ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Schreiben_hinterlaesst_keine_Nebendateien()
|
||||
{
|
||||
var result = await Run("""{"action":"write","path":"a.txt","content":"Inhalt"}""");
|
||||
result.Success.ShouldBeTrue();
|
||||
|
||||
// Das atomare Schreiben geht über eine Nebendatei — die muss danach weg sein.
|
||||
Directory.GetFiles(_root).ShouldHaveSingleItem();
|
||||
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("Inhalt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ueberschreiben_ersetzt_den_Inhalt_vollstaendig()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"ein sehr langer alter Inhalt"}""");
|
||||
await Run("""{"action":"write","path":"a.txt","content":"kurz"}""");
|
||||
|
||||
// Beim Ersetzen darf kein Rest der alten, längeren Fassung stehenbleiben.
|
||||
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("kurz");
|
||||
}
|
||||
|
||||
// ═══════════════════ #555: Nachschau bei write ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Fehlende_Datei_heisst_nichts_geschehen()
|
||||
{
|
||||
var befund = await Probe("""{"action":"write","path":"nie.txt","content":"x"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_beabsichtigte_Inhalt_heisst_durchgekommen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"fertig"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_alte_Inhalt_heisst_nicht_durchgekommen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"alt"}""");
|
||||
|
||||
// So sähe es aus, wenn der Absturz vor dem Ersetzen kam.
|
||||
var befund = await Probe("""{"action":"write","path":"a.txt","content":"neu"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
// ═══════════════════ #555: Nachschau bei append ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Vollstaendig_angehaengter_Text_wird_erkannt()
|
||||
{
|
||||
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
|
||||
await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_halb_angehaengter_Text_wird_als_Riss_erkannt()
|
||||
{
|
||||
// Anhängen ist nicht atomar — genau dieser Zustand kann wirklich entstehen.
|
||||
// Hier wird er von Hand hergestellt, weil ein echter Riss nicht reproduzierbar ist.
|
||||
var pfad = Path.Combine(_root, "log.txt");
|
||||
await File.WriteAllTextAsync(pfad, "Zeile 1\nZeile 2 ist nur halb ang");
|
||||
|
||||
var befund = await Probe(
|
||||
"""{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Partial);
|
||||
befund.Evidence.ShouldContain("von");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fehlt_der_Text_ganz_gilt_der_Aufruf_als_nicht_geschehen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
// ═══════════════════ Die Grenzen der Nachschau ═══════════════════
|
||||
|
||||
[Theory]
|
||||
[InlineData("read")]
|
||||
[InlineData("delete")]
|
||||
[InlineData("list")]
|
||||
public async Task Fuer_andere_Aktionen_wird_nichts_behauptet(string aktion)
|
||||
{
|
||||
var befund = await Probe($$"""{"action":"{{aktion}}","path":"a.txt"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_unbrauchbarer_Pfad_ergibt_Unbekannt_statt_einer_Ausnahme()
|
||||
{
|
||||
// Die Nachschau darf nie werfen: Ein Fehlschlag der Nachschau ist eine Aussage
|
||||
// über die Nachschau, nicht über die Welt.
|
||||
var befund = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullStateStore : IStateStore
|
||||
{
|
||||
public Task<string?> GetAsync(string key, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
Reference in New Issue
Block a user