Bugtracker-Durchgang: 13 Bugs behoben, Build ohne Warnungen

Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111):
Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact).

Sicherheit und Zielplattform
  #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft
       fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException)
       verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und
       der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem
       Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und
       TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen.
  #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt.
       Eine Schreibweise statt einer Sonderregel je Windows-Form.
  #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert
       zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis
       des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs.

Agentenkommunikation
  #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu
       MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat.
  #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und
       reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes
       Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein.
  #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld
       gelesen.

Betrieb und Oberflaeche
  #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces
       an ihrer Meldung - auch ueber Taktgrenzen hinweg.
  #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den
       anderen Spalten nicht den Platz nimmt.
  #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste.
  #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die
       helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar
       bleibt statt auf einen Wert zusammenzufallen.

Hygiene
  #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten.
  #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten
       Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5).
  #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die
       Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet
       jetzt, WELCHER Zugangsschluessel fehlt, statt an einer
       KeyNotFoundException zu scheitern.
  #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen
       Prompt-Groessen kalibrieren die Schaetzung je Modell.

Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer
Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten
Stand geprueft, damit belegt ist, dass er den Fehler faengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-03 19:04:48 +02:00
co-authored by Claude Opus 5
parent 740649789e
commit cffd6f4b77
44 changed files with 1942 additions and 228 deletions
+1
View File
@@ -21,6 +21,7 @@
<Project Path="src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ClawdDotNet.App.Tests/ClawdDotNet.App.Tests.csproj" />
<Project Path="tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj" />
<Project Path="tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj" />
</Folder>
+44 -5
View File
@@ -127,6 +127,45 @@ public sealed class AppHost : IAsyncDisposable
/// </summary>
public readonly record struct StartupResult(AppHost? Host, string? Error);
/// <summary>
/// Wohin protokolliert wird. Zwei Regeln, die vorher beide nicht galten:
///
/// <para><b>Leer heißt „nimm die Anwendungseinstellung".</b> Die steht absolut unter
/// <c>AppPaths.DataDirectory</c> und ist der Wert, den die Einstellungsseite zeigt.
/// Vorher war <c>InstanceConfig.LogDirectory</c> mit <c>"./Logs"</c> vorbelegt und
/// damit nie leer — der Zweig auf die Anwendungseinstellung war unerreichbar, und
/// das Feld auf der Einstellungsseite ließ sich ändern, ohne etwas zu ändern.</para>
///
/// <para><b>Ein relativer Wert zählt gegen das Instanzverzeichnis</b>, nicht gegen
/// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem
/// Verzeichnis jemand gestartet hat: Ein systemd-Dienst ohne <c>WorkingDirectory=</c>
/// startet in <c>/</c>, und <c>"./Logs"</c> zeigt dann auf <c>/Logs</c> — für ein
/// Dienstkonto nicht anlegbar. Beim Start aus dem Installationsverzeichnis schrieb
/// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt.</para>
/// </summary>
/// <param name="instance">Die geladene Instanz — liefert Vorrang und Bezugspunkt.</param>
/// <param name="applicationLogDirectory">Der Wert aus den Anwendungseinstellungen.</param>
/// <param name="instancePath">Das Verzeichnis, aus dem die Instanz geladen wurde.</param>
public static string ResolveLogDirectory(
InstanceConfig instance, string applicationLogDirectory, string instancePath)
{
var configured = !string.IsNullOrWhiteSpace(instance.LogDirectory)
? instance.LogDirectory
: applicationLogDirectory;
// InstanceConfig.WorkingDirectory steht seit CreateInstance mit dem absoluten
// Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann,
// wenn jemand das Feld von Hand geleert oder relativ gesetzt hat.
var instanceBase = Path.IsPathRooted(instance.WorkingDirectory)
? instance.WorkingDirectory
: instancePath;
return Path.GetFullPath(
Path.IsPathRooted(configured)
? configured
: Path.Combine(instanceBase, configured));
}
public static async Task<StartupResult> StartAsync(Callbacks callbacks, CancellationToken ct = default)
{
// ─── 1. Anwendungseinstellungen ───
@@ -153,10 +192,8 @@ public sealed class AppHost : IAsyncDisposable
}
// ─── 3. Protokollierung ───
var logDirectory = Path.GetFullPath(
!string.IsNullOrWhiteSpace(instance.LogDirectory)
? instance.LogDirectory
: settings.AppSettings.LogDirectory);
var logDirectory = ResolveLogDirectory(
instance, settings.AppSettings.LogDirectory, instancePath);
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
settings.AppSettings.MinimumLogLevel, true, out var parsed)
@@ -309,7 +346,9 @@ public sealed class AppHost : IAsyncDisposable
openRouter, tools, new PermissionGate(), stateStore, loggerFactory,
memory, usage, pricing, taskRepository, audit, stagingGate)
{
InstanceBudget = instance.Budget
InstanceBudget = instance.Budget,
MaxDelegationDepth = instance.MaxDelegationDepth,
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds)
};
engine.SetAgentConfigProvider(
@@ -120,7 +120,10 @@ public sealed class InstanceDirectoryManager
{
InstanceId = Guid.NewGuid().ToString("N")[..8],
InstanceName = instanceName,
LogDirectory = "./Logs",
// LogDirectory bleibt leer: dann gilt die Anwendungseinstellung, und die
// steht absolut. Hier stand vorher "./Logs" — ein Wert, der jede neue
// Instanz an das Arbeitsverzeichnis des Prozesses band und die
// Einstellungsseite wirkungslos machte.
WorkingDirectory = instanceDir
};
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
@@ -150,6 +153,17 @@ public sealed class InstanceDirectoryManager
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
// Migration: Die alte Vorbelegung "./Logs" wird zu „leer" — also zur
// Anwendungseinstellung. Der Wert hatte nie die Bedeutung, die er vorgab: Er
// wurde gegen das Arbeitsverzeichnis des Prozesses aufgelöst, nicht gegen das
// Instanzverzeichnis. Wer bewusst einen anderen Pfad eingetragen hat, behält
// ihn; nur genau die Vorbelegung fällt weg.
//
// Nur im Speicher: Die Datei bleibt unangetastet, damit ein Ladevorgang nichts
// zurückschreibt. Beim nächsten Speichern verschwindet der Wert von selbst.
if (config.LogDirectory is "./Logs" or @".\Logs" or "Logs")
config.LogDirectory = "";
// Agenten aus Verzeichnisstruktur laden
config.Agents.Clear();
var agentsDir = Path.Combine(instanceDir, "Agents");
+68 -7
View File
@@ -26,9 +26,18 @@ public sealed partial class LogTail(string logDirectory)
{
private readonly ConcurrentDictionary<string, long> _positions = new();
/// <summary>
/// Der zuletzt gesehene Zeitstempel je Datei. Trägt Fortsetzungszeilen über
/// Poll-Grenzen hinweg — siehe <see cref="ReadFile"/>.
/// </summary>
private readonly ConcurrentDictionary<string, string> _lastTimestamps = new();
[GeneratedRegex(@"\[(TRC|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.IgnoreCase)]
private static partial Regex LevelPattern();
[GeneratedRegex(@"^\[(\d{2}:\d{2}:\d{2}\.\d{3})\]")]
private static partial Regex TimestampPattern();
/// <summary>Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld.</summary>
public IReadOnlyList<string> AvailableModules()
{
@@ -44,7 +53,8 @@ public sealed partial class LogTail(string logDirectory)
}
/// <summary>
/// Alles, was seit dem letzten Aufruf dazugekommen ist.
/// Alles, was seit dem letzten Aufruf dazugekommen ist — in der Reihenfolge, in
/// der es entstanden ist.
///
/// <paramref name="module"/> leer heißt: alle Module.
/// </summary>
@@ -53,9 +63,15 @@ public sealed partial class LogTail(string logDirectory)
var directory = TodayDirectory();
if (directory is null) return [];
var result = new List<LogLine>();
var collected = new List<(string Timestamp, LogLine Line)>();
foreach (var path in Directory.GetFiles(directory, "*.log"))
// Nach Modulnamen geordnet einsammeln. Directory.GetFiles liefert unter Linux
// die Verzeichnisreihenfolge, nicht die alphabetische — was bei gleichem
// Zeitstempel die Reihenfolge der Ansicht von Lauf zu Lauf ändern könnte. Die
// stabile Sortierung weiter unten braucht einen festen Ausgangspunkt, sonst ist
// sie nur die halbe Zusage.
foreach (var path in Directory.GetFiles(directory, "*.log")
.OrderBy(p => p, StringComparer.Ordinal))
{
var moduleName = Path.GetFileNameWithoutExtension(path);
@@ -68,13 +84,29 @@ public sealed partial class LogTail(string logDirectory)
continue;
}
ReadFile(path, moduleName, level, result);
ReadFile(path, moduleName, level, collected);
}
return result;
// Jedes Modul schreibt in seine eigene Datei, eingesammelt wird Datei für
// Datei — also alphabetisch nach Modulname. Ohne diesen Schritt stehen die
// Zeilen in Blöcken je Modul statt in der Reihenfolge, in der sie entstanden
// sind. Das ist kein Schönheitsfehler: Wer einen Fehler sucht, liest die
// Reihenfolge der Ereignisse, und eine Ursache unter ihrer Wirkung schickt ihn
// an die falsche Stelle.
//
// OrderBy sortiert stabil — bei gleichem Zeitstempel bleibt die bisherige
// Ordnung, zusammengehörende Zeilen eines Moduls bleiben also beieinander.
// Verglichen wird die Zeichenkette [HH:mm:ss.fff]: Innerhalb eines
// Tagesverzeichnisses ist sie in derselben Ordnung sortierbar wie die Zeit,
// und ein Umweg über DateTime.Parse spart nichts.
return collected
.OrderBy(entry => entry.Timestamp, StringComparer.Ordinal)
.Select(entry => entry.Line)
.ToList();
}
private void ReadFile(string path, string module, LogLevelFilter level, List<LogLine> into)
private void ReadFile(string path, string module, LogLevelFilter level,
List<(string Timestamp, LogLine Line)> into)
{
var lastPosition = _positions.GetOrAdd(path, 0L);
@@ -99,9 +131,27 @@ public sealed partial class LogTail(string logDirectory)
if (string.IsNullOrWhiteSpace(line)) continue;
var lineLevel = ExtractLevel(line);
var timestamp = ExtractTimestamp(line);
if (timestamp.Length == 0)
{
// Fortsetzungszeile: Eine Ausnahme hängt ihren Stacktrace ohne
// eigenen Zeitstempel unter die Meldung. Sie erbt den Zeitstempel
// der Zeile davor, damit der Stacktrace bei seiner Meldung bleibt —
// auch dann, wenn die Meldung schon im vorigen Takt gelesen wurde
// und nur der Rest jetzt nachkommt.
timestamp = _lastTimestamps.GetValueOrDefault(path, "");
}
else
{
_lastTimestamps[path] = timestamp;
}
// Erst danach filtern: Eine weggefilterte Zeile darf den Zeitstempel
// trotzdem weiterreichen.
if (!Passes(lineLevel, level)) continue;
into.Add(new LogLine(module, lineLevel, line));
into.Add((timestamp, new LogLine(module, lineLevel, line)));
}
_positions[path] = stream.Position;
@@ -124,6 +174,17 @@ public sealed partial class LogTail(string logDirectory)
return match.Success ? match.Groups[1].Value.ToUpperInvariant() : "";
}
/// <summary>
/// Der Zeitstempel am Zeilenanfang, oder leer bei einer Fortsetzungszeile.
/// Nur am Anfang, nicht irgendwo: Eine Meldung darf eine Uhrzeit enthalten, ohne
/// dass sie zum Sortierschlüssel wird.
/// </summary>
private static string ExtractTimestamp(string line)
{
var match = TimestampPattern().Match(line);
return match.Success ? match.Groups[1].Value : "";
}
private static bool Passes(string lineLevel, LogLevelFilter filter) => filter switch
{
LogLevelFilter.All => true,
+31 -1
View File
@@ -16,8 +16,17 @@ public sealed class InstanceConfig
[JsonPropertyName("workingDirectory")]
public string WorkingDirectory { get; set; } = "./data/";
/// <summary>
/// Wohin diese Instanz protokolliert. Leer heißt: das Log-Verzeichnis aus den
/// Anwendungseinstellungen, also der absolute Pfad unter <c>AppPaths.DataDirectory</c>.
///
/// Die frühere Vorbelegung <c>"./Logs"</c> war nie leer und verdeckte damit die
/// Anwendungseinstellung vollständig; aufgelöst wurde sie zudem gegen das
/// Arbeitsverzeichnis des Prozesses. Ein relativer Wert zählt heute gegen das
/// Instanzverzeichnis (siehe <c>AppHost.StartAsync</c>).
/// </summary>
[JsonPropertyName("logDirectory")]
public string LogDirectory { get; set; } = "./Logs";
public string LogDirectory { get; set; } = "";
[JsonPropertyName("webServerPort")]
public int WebServerPort { get; set; } = 8080;
@@ -33,6 +42,27 @@ public sealed class InstanceConfig
[JsonPropertyName("budget")]
public InstanceBudget Budget { get; set; } = new();
/// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8, #206).
/// A→B ist die Tiefe 1. Zyklen fängt die Kette selbst ab; dieser Wert begrenzt die
/// Staffel A→B→C→D, die niemand beauftragt hat und die jeden Schritt einen vollen
/// Lauf kostet.
///
/// Instanzweit und nicht je Agent: Eine Kette gehört keinem der Beteiligten.
/// </summary>
[JsonPropertyName("maxDelegationDepth")]
public int MaxDelegationDepth { get; set; } = 3;
/// <summary>
/// Wie lange ein delegierter Aufruf höchstens auf einen belegten Zielagenten
/// wartet, in Sekunden (#461). Danach kommt eine Absage statt weiterer Wartezeit.
///
/// Gilt nur für Delegationen. Ein vom Menschen angestoßener Chat reiht sich
/// weiterhin ein und wartet, so lange es dauert.
/// </summary>
[JsonPropertyName("delegationGateTimeoutSeconds")]
public int DelegationGateTimeoutSeconds { get; set; } = 120;
[JsonPropertyName("agents")]
public List<AgentConfig> Agents { get; set; } = new();
+164 -38
View File
@@ -32,6 +32,36 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>Tagesgrenzen der Instanz. Wird vom Host gesetzt.</summary>
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
/// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
///
/// Zyklen fängt schon die Kette selbst ab — dieser Wert begrenzt die Staffel, die
/// keiner ist: A beauftragt B, B beauftragt C, C beauftragt D. Jeder Schritt hält
/// das Gate seines Vorgängers und kostet einen vollen Lauf; drei Weitergaben sind
/// mehr, als eine sinnvolle Arbeitsteilung braucht.
///
/// Instanzweit und nicht je Agent: Eine Kette gehört keinem der Beteiligten.
/// </summary>
public int MaxDelegationDepth { get; set; } = 3;
/// <summary>
/// Wie lange ein <b>delegierter</b> Lauf höchstens auf das Gate seines Zielagenten
/// wartet (#461). Danach kommt eine Absage statt weiterer Wartezeit.
///
/// <para>Die Delegationskette schließt Zyklen aus, aber nicht das Über-Kreuz: Lauf 1
/// startet bei A und beauftragt B, Lauf 2 startet zeitgleich bei B und beauftragt A.
/// Beide Ketten sind für sich zyklusfrei — trotzdem hält jeder das Gate, auf das der
/// andere wartet. Eine Kette kann das grundsätzlich nicht sehen; nur eine Frist
/// löst es auf.</para>
///
/// <para>Nur für delegierte Läufe. Ein vom Menschen angestoßener Chat reiht sich
/// weiterhin ein und wartet, so lange es dauert — dort gibt es niemanden, der
/// verklemmen könnte, und ein Abbruch wäre nur lästig.</para>
/// </summary>
public TimeSpan DelegationGateTimeout { get; set; } = TimeSpan.FromSeconds(120);
private readonly ContextCompactor _compactor;
private readonly Dictionary<string, List<ChatEntry>> _chatHistories = new();
@@ -112,14 +142,16 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
string instanceId,
CancellationToken externalCt,
string? source = null,
string? taskId = null)
string? taskId = null,
DelegationChain? delegation = null)
{
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
return denied;
var runId = Guid.NewGuid().ToString("N");
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source);
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain);
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
@@ -131,7 +163,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
string instanceId,
CancellationToken externalCt,
string runId,
string? source)
string? source,
DelegationChain chain)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
@@ -166,6 +199,14 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
ct.ThrowIfCancellationRequested();
loopGuard.RecordStep();
// Kompaktieren vor dem Aufruf — dieselbe Reihenfolge wie in ChatCoreAsync.
// Verhindert die Token-Explosion bei komplexen Analysen, und zwar bevor
// sie bezahlt ist statt danach.
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct))
{
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
}
if (cachingEnabled)
PromptCache.ApplyBreakpoints(messages);
@@ -180,22 +221,15 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
: null
};
var estimated = _compactor.EstimateFor(agentConfig.Model, messages);
var response = await _client.CompleteAsync(request, ct);
var promptTokens = 0;
if (response.Usage is not null)
{
tally.Add(response.Usage);
promptTokens = response.Usage.PromptTokens;
loopGuard.RecordTokens(response.Usage.TotalTokens);
}
// Context-Compaction auch in RunAsync – verhindert Token-Explosion bei komplexen Analysen
var compacted = await _compactor.CompactIfNeededAsync(
messages, promptTokens, agentConfig.LoopGuard, agentConfig.Model, ct);
if (compacted)
{
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
_compactor.Calibrate(agentConfig.Model, estimated, response.Usage.PromptTokens);
}
var choice = response.Choices.FirstOrDefault();
@@ -214,7 +248,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
foreach (var toolCall in assistantMessage.ToolCalls)
{
var toolResult = await ExecuteToolCallAsync(
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source, chain);
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
}
@@ -303,12 +337,14 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
string instanceId,
CancellationToken externalCt,
string? source = null,
string? taskId = null)
string? taskId = null,
DelegationChain? delegation = null)
{
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
return denied;
var runId = Guid.NewGuid().ToString("N");
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
// Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
// wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
@@ -316,9 +352,24 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
RegisterRun(agentConfig.AgentId, runCts);
var gate = GetAgentGate(agentConfig.AgentId);
// Ein delegierter Lauf wartet nur begrenzt (#461): Zwei Ketten über Kreuz
// halten jeweils das Gate, auf das die andere wartet, und keine der beiden
// sieht die andere. Ein vom Menschen angestoßener Chat wartet dagegen weiter,
// so lange es dauert — dort gibt es niemanden, der verklemmen könnte.
var gateWait = chain.Depth > 0 ? DelegationGateTimeout : Timeout.InfiniteTimeSpan;
try
{
await gate.WaitAsync(runCts.Token);
if (!await gate.WaitAsync(gateWait, runCts.Token))
{
UnregisterRun(agentConfig.AgentId, runCts);
return new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.TargetBusy,
$"[Agent belegt: '{agentConfig.AgentId}' hat innerhalb von " +
$"{gateWait.TotalSeconds:0} Sekunden nicht frei gehabt]",
0, 0, TimeSpan.Zero);
}
}
catch (OperationCanceledException)
{
@@ -330,7 +381,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
try
{
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain);
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
return result;
@@ -348,7 +399,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
string instanceId,
CancellationToken runCt,
string? source,
string runId)
string runId,
DelegationChain chain)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
@@ -398,6 +450,16 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
ct.ThrowIfCancellationRequested();
loopGuard.RecordStep();
// Kompaktieren VOR dem Aufruf. Vorher lief die Prüfung danach und mit
// den promptTokens der gerade bezahlten Anfrage: Der überfüllte Prompt
// war zu dem Zeitpunkt längst abgerechnet, gekürzt wurde erst für den
// nächsten Schritt. Genau ein Schritt zu spät, und der teuerste.
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct))
{
PersistChatState(agentConfig.AgentId);
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
}
if (cachingEnabled)
PromptCache.ApplyBreakpoints(messages);
@@ -412,23 +474,18 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
: null
};
// 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
// einzige Rückmeldung, die die Schätzung je bekommt.
var estimated = _compactor.EstimateFor(agentConfig.Model, messages);
var response = await _client.CompleteAsync(request, ct);
var promptTokens = 0;
if (response.Usage is not null)
{
tally.Add(response.Usage);
promptTokens = response.Usage.PromptTokens;
loopGuard.RecordTokens(response.Usage.TotalTokens);
}
// Context-Compaction nach API-Response prüfen
var compacted = await _compactor.CompactIfNeededAsync(
messages, promptTokens, agentConfig.LoopGuard, agentConfig.Model, ct);
if (compacted)
{
PersistChatState(agentConfig.AgentId);
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
_compactor.Calibrate(agentConfig.Model, estimated, response.Usage.PromptTokens);
}
var choice = response.Choices.FirstOrDefault();
@@ -447,7 +504,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
foreach (var toolCall in assistantMessage.ToolCalls)
{
var toolResult = await ExecuteToolCallAsync(
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source, chain);
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
}
@@ -700,6 +757,37 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// ─── IAgentMessageRouter ───
/// <summary>
/// Darf dieser Aufruf weitergegeben werden (B8)? Gibt den Ablehnungsgrund zurück
/// oder <c>null</c>, wenn nichts dagegen spricht.
///
/// <para>Der Grund geht als Ergebnis an den aufrufenden Agenten zurück, nicht als
/// Ausnahme: Er soll lesen können, warum es nicht geht, und einen anderen Weg
/// wählen. Deshalb steht die ganze Kette in der Meldung — „Zyklus" allein sagt
/// niemandem, welcher.</para>
/// </summary>
private string? CheckDelegation(DelegationChain? chain, string fromAgentId, string toAgentId)
{
// Ohne Kette ist der Aufruf von aussen angestossen: Der Absender ist der erste
// in der Kette, weitergegeben wurde noch nichts.
var callerChain = chain ?? DelegationChain.Root(fromAgentId);
// Der eigentliche Deadlock. Der Zielagent hält sein Gate schon irgendwo weiter
// oben in dieser Kette und gibt es erst frei, wenn dieser Aufruf zurückkommt —
// was er nur könnte, wenn das Gate frei wäre.
if (callerChain.Contains(toAgentId))
return $"Zyklus in der Delegationskette: {callerChain} -> {toAgentId}. " +
$"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " +
"kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren.";
if (callerChain.Depth >= MaxDelegationDepth)
return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " +
$"{MaxDelegationDepth} Weitergaben erreicht. Erledige den Rest selbst " +
"oder gib die Aufgabe an den Auftraggeber zurück.";
return null;
}
public IReadOnlyList<AgentInfo> ListAgents(string callerAgentId)
{
var configs = _agentConfigProvider?.Invoke() ?? [];
@@ -709,8 +797,19 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
.ToList();
}
/// <summary>
/// Die Instanz des Aufrufers, sonst die der Engine (B13).
///
/// <c>_instanceId</c> wird nur in <c>SetAgentConfigProvider</c> gesetzt und war
/// vorher die einzige Quelle — ein Lauf, der mit einer anderen Instanz-Id
/// angestoßen wurde, verlor sie beim Weiterreichen, und ohne den Aufruf war sie leer.
/// </summary>
private string ResolveInstanceId(string? fromCaller)
=> string.IsNullOrWhiteSpace(fromCaller) ? _instanceId : fromCaller;
public async Task<AgentMessageResult> SendMessageAsync(
string fromAgentId, string toAgentId, string message, CancellationToken ct)
string fromAgentId, string toAgentId, string message,
string? instanceId, DelegationChain? chain, CancellationToken ct)
{
var configs = _agentConfigProvider?.Invoke();
if (configs is null)
@@ -726,6 +825,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (targetConfig is null)
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused)
return new AgentMessageResult(false, null, refused);
var callerChain = chain ?? DelegationChain.Root(fromAgentId);
var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId);
var fromName = fromConfig?.DisplayName ?? fromAgentId;
@@ -733,13 +837,23 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
try
{
var result = await ChatAsync(targetConfig, wrappedMessage, _instanceId, ct, source: ChatSource.AgentComm);
var result = await ChatAsync(targetConfig, wrappedMessage, ResolveInstanceId(instanceId), ct,
source: ChatSource.AgentComm, delegation: callerChain.Append(toAgentId));
return result.Status switch
{
AgentRunStatus.Completed => new AgentMessageResult(true, result.FinalMessage),
AgentRunStatus.LoopLimitExceeded => new AgentMessageResult(true, result.FinalMessage,
"Agent hat das Step-Limit erreicht, die Nachricht wurde aber zugestellt."),
// #461: Das Ziel war die ganze Frist über belegt. Für den fragenden
// Agenten ist das kein Fehler, sondern eine Auskunft — er kann es
// später erneut versuchen oder ohne die Antwort weiterarbeiten.
AgentRunStatus.TargetBusy => new AgentMessageResult(false, null,
$"Agent '{targetConfig.DisplayName}' ({toAgentId}) ist belegt und hat innerhalb " +
$"von {DelegationGateTimeout.TotalSeconds:0} Sekunden nicht frei gehabt. " +
"Später erneut versuchen oder ohne diese Antwort weitermachen."),
_ => new AgentMessageResult(false, null, $"Agent run status: {result.Status}")
};
}
@@ -750,7 +864,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
}
public async Task<AgentSpawnResult> SpawnAgentAsync(
string fromAgentId, string targetAgentId, string taskMessage, CancellationToken ct)
string fromAgentId, string targetAgentId, string taskMessage,
string? instanceId, DelegationChain? chain, CancellationToken ct)
{
var configs = _agentConfigProvider?.Invoke();
if (configs is null)
@@ -760,6 +875,13 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (targetConfig is null)
return new AgentSpawnResult(false, targetAgentId, null, $"Agent '{targetAgentId}' not found.");
// Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits
// delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen.
if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused)
return new AgentSpawnResult(false, targetAgentId, null, refused);
var callerChain = chain ?? DelegationChain.Root(fromAgentId);
if (IsRunning(targetAgentId))
return new AgentSpawnResult(false, targetAgentId, null,
$"Agent '{targetConfig.DisplayName}' läuft bereits. Verwende send_message statt spawn.");
@@ -771,7 +893,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
try
{
var result = await RunAsync(targetConfig, wrappedMessage, _instanceId, ct);
var result = await RunAsync(targetConfig, wrappedMessage, ResolveInstanceId(instanceId), ct,
delegation: callerChain.Append(targetAgentId));
return result.Status == AgentRunStatus.Completed
? new AgentSpawnResult(true, targetAgentId, result.FinalMessage)
@@ -842,7 +965,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
ILogger logger,
CancellationToken ct,
string runId,
string? source)
string? source,
DelegationChain chain)
{
var toolName = toolCall.Function.Name;
var arguments = toolCall.Function.Arguments ?? "";
@@ -889,7 +1013,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
? default
: JsonDocument.Parse(toolCall.Function.Arguments).RootElement;
var context = BuildToolContext(agentConfig, instanceId, toolName, ct);
var context = BuildToolContext(agentConfig, instanceId, toolName, ct, chain);
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
@@ -936,7 +1060,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
}
private AgentToolContext BuildToolContext(
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct)
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct,
DelegationChain? chain = null)
{
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
@@ -955,7 +1080,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
agentConfig.SharedWorkspacePath,
this,
_memoryRepository,
_taskRepository);
_taskRepository,
chain ?? DelegationChain.Root(agentConfig.AgentId));
}
/// <summary>
@@ -31,5 +31,12 @@ public enum AgentRunStatus
LoopLimitExceeded,
/// <summary>Der Lauf fand nicht statt, weil das Tagesbudget erschöpft ist.</summary>
BudgetExceeded
BudgetExceeded,
/// <summary>
/// Der Lauf fand nicht statt, weil der Agent belegt war und der Aufruf nicht
/// unbegrenzt warten darf (#461). Trifft nur delegierte Läufe — ein vom Menschen
/// angestoßener Chat reiht sich weiterhin ein und wartet, so lange es dauert.
/// </summary>
TargetBusy
}
@@ -14,12 +14,32 @@ public sealed class ContextCompactor
private const int MaxToolResultChars = 2000;
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
/// <summary>
/// Wie stark ein einzelner Messwert den Faktor verschiebt. Klein genug, dass ein
/// Ausreißer die Schwelle nicht kippt, groß genug, dass ein Modellwechsel nach
/// wenigen Aufrufen ankommt.
/// </summary>
private const double CalibrationWeight = 0.25;
/// <summary>
/// Je Modell: um welchen Faktor die rohe Schätzung danebenlag. 1,0 heißt „noch nichts
/// gemessen". Modellweise, weil verschiedene Tokenizer denselben Text verschieden
/// zerlegen — ein gemeinsamer Faktor wäre der Mittelwert von etwas, das nichts
/// miteinander zu tun hat.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, double> _calibration = new();
public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory)
{
_client = client;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
}
/// <summary>
/// Die rohe Schätzung: vier Zeichen je Token. Grob, aber ohne Netzaufruf und ohne
/// Kenntnis des Tokenizers — und darauf kommt es an, weil sie <b>vor</b> dem Senden
/// gebraucht wird. <see cref="EstimateFor"/> zieht die gemessene Abweichung nach.
/// </summary>
public static int EstimateTokens(List<ChatMessage> messages)
{
var totalChars = 0;
@@ -37,16 +57,51 @@ public sealed class ContextCompactor
return totalChars / 4;
}
/// <summary>
/// Die Schätzung für ein bestimmtes Modell — die rohe Rechnung, korrigiert um das,
/// was bei diesem Modell bisher tatsächlich gemessen wurde.
/// </summary>
public int EstimateFor(string model, List<ChatMessage> messages)
=> (int)(EstimateTokens(messages) * _calibration.GetValueOrDefault(model, 1.0));
/// <summary>
/// Meldet, wie groß der Prompt wirklich war, den eine Schätzung vorhergesagt hat.
///
/// <para>Vier Zeichen je Token stimmt für englische Fließtexte ungefähr und liegt
/// bei deutschem Text, JSON und Code daneben — je nach Modell in beide Richtungen.
/// Der Faktor zieht die Schätzung dorthin, wo dieses Modell tatsächlich liegt.</para>
///
/// <para>Gleitender Mittelwert statt letztem Messwert: Ein einzelner Ausreißer soll
/// die Schwelle nicht verschieben. Begrenzt auf das Halbe bis Vierfache — was
/// darüber hinausginge, ist kein Kalibrierungsfall mehr, sondern ein Fehler
/// woanders, und den soll die Schätzung nicht kaschieren.</para>
/// </summary>
public void Calibrate(string model, int estimatedTokens, int actualPromptTokens)
{
if (estimatedTokens <= 0 || actualPromptTokens <= 0) return;
var gemessen = Math.Clamp((double)actualPromptTokens / estimatedTokens, 0.5, 4.0);
// Auch der erste Messwert zählt nur mit seinem Gewicht — Ausgangspunkt ist die
// rohe Schätzung (Faktor 1,0). Setzte der erste Wert den Faktor voll, könnte ein
// einzelner Ausreißer die Schwelle sofort verschieben, und genau das soll der
// gleitende Mittelwert verhindern.
static double Naechster(double bisher, double gemessen)
=> bisher * (1 - CalibrationWeight) + gemessen * CalibrationWeight;
_calibration.AddOrUpdate(
model,
Naechster(1.0, gemessen),
(_, bisher) => Naechster(bisher, gemessen));
}
public async Task<bool> CompactIfNeededAsync(
List<ChatMessage> messages,
int lastPromptTokens,
LoopGuardConfig guard,
string model,
CancellationToken ct)
{
var contextTokens = lastPromptTokens > 0
? lastPromptTokens
: EstimateTokens(messages);
var contextTokens = EstimateFor(model, messages);
var threshold = (int)(guard.MaxContextTokens * guard.CompactionThreshold);
+55 -18
View File
@@ -55,13 +55,18 @@ public static class PathBoundary
/// den abschließenden Trenner gälte <c>…/Workspace-Backup</c> als Teil von
/// <c>…/Workspace</c>.
///
/// Symbolische Verknüpfungen werden aufgelöst (<see cref="Canonicalize"/>) — ein
/// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb.
/// Symbolische Verknüpfungen werden aufgelöst (<see cref="TryCanonicalize"/>) — ein
/// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb. Steht einer
/// der beiden Pfade nicht fest, gilt der Kandidat als außerhalb: Ein unaufgelöster
/// Pfad besteht die Präfixprüfung gerade deshalb, weil er unaufgelöst ist.
/// </summary>
public static bool IsInside(string candidate, string root)
{
var normalizedRoot = Canonicalize(NormalizeDirectory(root));
var normalizedCandidate = Canonicalize(Path.GetFullPath(candidate));
if (!TryCanonicalize(NormalizeDirectory(root), out var normalizedRoot) ||
!TryCanonicalize(Path.GetFullPath(candidate), out var normalizedCandidate))
{
return false;
}
// Der Root selbst gilt als innerhalb.
if (string.Equals(
@@ -90,6 +95,17 @@ public static class PathBoundary
: full;
}
/// <summary>
/// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
/// </summary>
/// <exception cref="IOException">
/// Der Pfad ist nicht bestimmbar. <see cref="TryCanonicalize"/> meldet das ohne Wurf.
/// </exception>
public static string Canonicalize(string path)
=> TryCanonicalize(path, out var canonical)
? canonical
: throw new IOException($"Pfad nicht bestimmbar: {path}");
/// <summary>
/// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
///
@@ -101,15 +117,21 @@ public static class PathBoundary
///
/// Noch nicht existierende Abschnitte bleiben unverändert — für einen Pfad, der erst
/// angelegt werden soll, ist das der richtige Umgang: Was es nicht gibt, kann keine
/// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft.
/// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft. Unverändert
/// bleibt dabei genau dieser eine Abschnitt; was davor aufgelöst wurde, zählt weiter.
///
/// Rückgabe <c>false</c> heißt: Der Pfad steht nicht fest (Zyklus, zu tiefe Kette,
/// gesperrtes Verzeichnis). Dann darf niemand mit ihm weiterrechnen — die
/// Einschließungsprüfung weist ab, statt den unaufgelösten Pfad durchzureichen.
/// </summary>
public static string Canonicalize(string path)
public static bool TryCanonicalize(string path, out string canonical)
{
var full = Path.GetFullPath(path);
canonical = full;
var root = Path.GetPathRoot(full);
if (string.IsNullOrEmpty(root))
return full;
return true;
var rest = full[root.Length..]
.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
@@ -124,22 +146,36 @@ public static class PathBoundary
string? target;
try
{
// Gibt null zurück, wenn der Abschnitt keine Verknüpfung ist oder
// nicht existiert. Wirft bei Zyklen und bei zu tiefen Ketten.
target = Directory.Exists(current)
? Directory.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName
: File.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName;
// LinkTarget fragt den Eintrag, ohne ihm zu folgen: null heißt „keine
// Verknüpfung" und ebenso „gibt es noch nicht". Beides ist derselbe
// Fall — der Abschnitt bleibt, wie er ist.
//
// Der frühere Weg über File.ResolveLinkTarget führte hier in die Irre:
// Für einen fehlenden Abschnitt wirft der Aufruf FileNotFoundException,
// also eine IOException, und der Fang darunter verließ die ganze
// Auflösung mit dem unaufgelösten Pfad. Da der jede Präfixprüfung
// besteht, war der Symlink-Schutz bei jedem Schreibvorgang wirkungslos.
FileSystemInfo entry = Directory.Exists(current)
? new DirectoryInfo(current)
: new FileInfo(current);
if (entry.LinkTarget is null)
continue;
// Folgt der ganzen Kette. Hängt sie ins Leere, bleibt das unmittelbare
// Ziel — die Richtung stimmt, und darauf kommt es an.
target = entry.ResolveLinkTarget(returnFinalTarget: true)?.FullName
?? entry.LinkTarget;
}
catch (IOException)
{
// Zyklus oder unauflösbare Kette. Der Pfad ist damit nicht bestimmbar —
// wir geben zurück, was wir haben. Die Einschließungsprüfung entscheidet
// dann auf der bisherigen, unaufgelösten Fassung: Sie weist im Zweifel ab.
return full;
// Zyklus oder zu tiefe Kette: Wohin der Pfad zeigt, ist nicht zu
// ermitteln. Fail closed — der Aufrufer bekommt kein Ergebnis.
return false;
}
catch (UnauthorizedAccessException)
{
return full;
return false;
}
if (!string.IsNullOrEmpty(target))
@@ -150,6 +186,7 @@ public static class PathBoundary
}
}
return Path.GetFullPath(current);
canonical = Path.GetFullPath(current);
return true;
}
}
+48 -7
View File
@@ -108,11 +108,7 @@ public static class TaskSchedule
if (matchLocal is null) return null;
// Ungültige Ortszeit (Sprung bei der Zeitumstellung) darf nicht werfen.
var unspecified = DateTime.SpecifyKind(matchLocal.Value, DateTimeKind.Unspecified);
if (tz.IsInvalidTime(unspecified)) return null;
var occUtc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
if (!TryToUtc(matchLocal.Value, tz, out var occUtc)) return null;
var occ = Iso(occUtc);
// Nur fällig, wenn der Termin echt über dem Marker liegt.
@@ -145,12 +141,57 @@ public static class TaskSchedule
var tz = ResolveTimeZone(timeZone);
if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar
var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
if (tz.IsInvalidTime(unspecified)) return false;
return TryToUtc(local, tz, out utc);
}
return false;
}
/// <summary>
/// Rechnet eine Wanduhrzeit in ihren UTC-Zeitpunkt um — auch dann, wenn es diese
/// Uhrzeit an diesem Tag gar nicht gibt.
///
/// <para><b>Der Frühjahrssprung.</b> In der Nacht der Umstellung springt die Uhr von
/// 02:00 auf 03:00; „02:30" existiert an diesem Tag nicht. Vorher fiel ein Termin
/// darin schlicht aus — einmal im Jahr lief eine tägliche Aufgabe nicht, und weil
/// nichts scheiterte, fiel es niemandem auf. Ein ausgefallener Lauf ist kein
/// harmloseres Ergebnis als ein verschobener, nur ein leiseres.</para>
///
/// <para>Der Termin wandert deshalb auf den ersten Zeitpunkt, den es nach der Lücke
/// gibt — aus 02:30 wird 03:00, nicht 03:30. So spät wie nötig, so früh wie möglich;
/// die Reihenfolge mehrerer Termine in derselben Lücke bleibt dabei erhalten, weil
/// sie alle auf denselben Zeitpunkt fallen und der Marker den zweiten abfängt.</para>
///
/// <para><b>Der Herbstsprung</b> braucht nichts Eigenes: Eine doppelt vorkommende
/// Uhrzeit rechnet <c>ConvertTimeToUtc</c> auf die Normalzeit-Fassung um, also auf
/// den zweiten Durchlauf. Beide Durchläufe ergeben denselben Occurrence-Key, und der
/// Marker sorgt dafür, dass nur einer davon feuert.</para>
/// </summary>
private static bool TryToUtc(DateTime local, TimeZoneInfo tz, out DateTime utc)
{
var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
if (!tz.IsInvalidTime(unspecified))
{
utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
return true;
}
// Minutenweise vorrücken, bis die Uhr wieder eine Zeit zeigt, die es gibt.
// Die Obergrenze ist Vorsicht, kein Fachwissen: Die größte gebräuchliche Lücke
// ist eine Stunde (Lord Howe Island: 30 Minuten). Bräuchte es je mehr als drei,
// stimmt etwas anderes nicht, und dann ist „gar nicht" das ehrlichere Ergebnis.
for (var probe = unspecified.AddMinutes(1);
probe <= unspecified.AddHours(3);
probe = probe.AddMinutes(1))
{
if (tz.IsInvalidTime(probe)) continue;
utc = TimeZoneInfo.ConvertTimeToUtc(probe, tz);
return true;
}
utc = default;
return false;
}
@@ -16,5 +16,12 @@ public sealed record AgentToolContext(
string? SharedWorkspacePath = null,
IAgentMessageRouter? MessageRouter = null,
IMemoryRepository? Memory = null,
ITaskRepository? Tasks = null
ITaskRepository? Tasks = null,
/// <summary>
/// Wer an diesem Aufruf schon beteiligt ist. Werkzeuge, die einen anderen Agenten
/// beauftragen, reichen die Kette weiter — sonst kann die Engine einen Zyklus
/// nicht von einer neuen Delegation unterscheiden. Siehe <see cref="DelegationChain"/>.
/// </summary>
DelegationChain? Delegation = null
);
@@ -0,0 +1,56 @@
namespace ClawdDotNet.Core.Tools;
/// <summary>
/// Wer an diesem Aufruf schon beteiligt ist — die Kette vom ersten Agenten bis zum
/// gerade laufenden.
///
/// <para><b>Warum es die Kette gibt (B8).</b> <c>send_message</c> ist ein synchroner
/// Aufruf: A hält sein Gate, während es auf B wartet. Ruft B nun A, wartet B auf ein
/// Gate, das A hält und erst freigibt, wenn B geantwortet hat. Das löste nur der
/// Timeout des äußeren Laufs auf — bis dahin standen beide Agenten.</para>
///
/// <para><b>Die Zusagen</b>, auf denen alles Weitere aufsetzt:</para>
/// <list type="number">
/// <item>Ein Agent kommt in einer Kette höchstens einmal vor. Damit wartet niemand
/// auf ein Gate, das er in derselben Kette schon hält — der Verklemmung ist die
/// Grundlage entzogen, nicht nur die Wahrscheinlichkeit genommen.</item>
/// <item>Eine Kette ist höchstens <c>MaxDelegationDepth</c> Schritte lang. Zyklen
/// sind damit ausgeschlossen, eine lange Staffel A→B→C→D→… aber nicht; die
/// kostet Zeit und Tokens, ohne dass jemand sie beauftragt hätte.</item>
/// <item>Die Kette gehört zum <b>Aufruf</b>, nicht zum Agenten. Zwei unabhängige
/// Läufe desselben Agenten behindern einander nicht.</item>
/// </list>
///
/// <para>Unveränderlich: <see cref="Append"/> gibt eine neue Kette zurück. Ein Aufruf
/// kann die Kette seines Aufrufers deshalb nicht verändern, und die Kette lässt sich
/// gefahrlos über Fadengrenzen reichen.</para>
/// </summary>
public sealed class DelegationChain
{
private readonly string[] _agents;
private DelegationChain(string[] agents) => _agents = agents;
/// <summary>Der Anfang: ein Lauf, den niemand delegiert hat.</summary>
public static DelegationChain Root(string agentId) => new([agentId]);
/// <summary>Die Agenten in der Reihenfolge, in der sie beauftragt wurden.</summary>
public IReadOnlyList<string> Agents => _agents;
/// <summary>
/// Die Zahl der Weitergaben — nicht die der Agenten. Ein Lauf ohne Delegation hat
/// die Tiefe 0, A→B die Tiefe 1.
/// </summary>
public int Depth => _agents.Length - 1;
/// <summary>Ist dieser Agent in der Kette schon vertreten?</summary>
public bool Contains(string agentId)
=> Array.IndexOf(_agents, agentId) >= 0;
/// <summary>Die Kette mit einem weiteren Agenten am Ende.</summary>
public DelegationChain Append(string agentId)
=> new([.. _agents, agentId]);
/// <summary>Für Meldungen und Protokoll: <c>A -> B -> C</c>.</summary>
public override string ToString() => string.Join(" -> ", _agents);
}
@@ -10,15 +10,32 @@ public interface IAgentMessageRouter
{
IReadOnlyList<AgentInfo> ListAgents(string callerAgentId);
/// <param name="instanceId">
/// Die Instanz des aufrufenden Laufs — aus <see cref="AgentToolContext.InstanceId"/>
/// durchgereicht. Leer heißt „nimm die Instanz, mit der die Engine eingerichtet
/// wurde". Vorher galt immer Letzteres, auch wenn der Lauf mit einer anderen
/// Instanz-Id angestoßen worden war (B13).
/// </param>
/// <param name="chain">
/// Die Delegationskette des aufrufenden Laufs — aus
/// <see cref="AgentToolContext.Delegation"/> durchgereicht. <c>null</c> heißt
/// „von aussen angestossen", die Kette beginnt dann beim Absender. Ohne sie kann
/// die Engine A→B→A nicht von einer neuen Delegation unterscheiden (B8).
/// </param>
Task<AgentMessageResult> SendMessageAsync(
string fromAgentId,
string toAgentId,
string message,
string? instanceId,
DelegationChain? chain,
CancellationToken ct);
/// <inheritdoc cref="SendMessageAsync"/>
Task<AgentSpawnResult> SpawnAgentAsync(
string fromAgentId,
string targetAgentId,
string taskMessage,
string? instanceId,
DelegationChain? chain,
CancellationToken ct);
}
+23 -6
View File
@@ -31,11 +31,25 @@
<Color x:Key="TextColor">#1d1f20</Color>
<Color x:Key="AccentColor">#5980a6</Color>
<!-- Text in Abstufungen: 70 % / 60 % / 55 % / 50 % Deckkraft -->
<SolidColorBrush x:Key="TextMutedBrush" Color="#b31d1f20" />
<SolidColorBrush x:Key="TextSubtleBrush" Color="#991d1f20" />
<SolidColorBrush x:Key="TextFaintBrush" Color="#8c1d1f20" />
<SolidColorBrush x:Key="TextGhostBrush" Color="#801d1f20" />
<!-- Text in Abstufungen: 78 % / 72 % / 67 % / 64 % Deckkraft.
Die Werte stehen je Thema getrennt, obwohl beide Themen dieselbe Staffelung
meinen: Die Gruende sind verschieden hell, also traegt derselbe Alphawert
verschieden weit. Vorher galten hier die Werte der dunklen Palette
(b3/99/8c/80); dort reichen sie, hier ergaben drei von vier weniger als die
4,5:1, die WCAG AA fuer normalen Text verlangt — TextGhost als
PlaceholderForeground der TextBox lag bei 3,17:1.
Nachgerechnet aus den Farbwerten, gegen BgBrush und SurfaceBrush:
TextMuted 7,60 / 7,23
TextSubtle 6,20 / 5,97
TextFaint 5,32 / 5,13
TextGhost 4,84 / 4,67
Die volle Deckung liegt bei 14,79 / 13,64 — die Staffelung bleibt also
sichtbar, statt auf einen Wert zusammenzufallen. -->
<SolidColorBrush x:Key="TextMutedBrush" Color="#c81d1f20" />
<SolidColorBrush x:Key="TextSubtleBrush" Color="#b81d1f20" />
<SolidColorBrush x:Key="TextFaintBrush" Color="#ac1d1f20" />
<SolidColorBrush x:Key="TextGhostBrush" Color="#a41d1f20" />
<!-- Zustaende: aus der Textfarbe abgeleitet, damit sie auf jedem Grund sitzen -->
<SolidColorBrush x:Key="HoverBrush" Color="#121d1f20" />
@@ -96,10 +110,13 @@
<Color x:Key="TextColor">#eef0f1</Color>
<Color x:Key="AccentColor">#7ea3c6</Color>
<!-- Kontrast gegen BgBrush / SurfaceBrush: 7,94/7,31 · 6,13/5,79 · 5,33/5,05 ·
5,13/4,86. Nur TextGhost ist angehoben (0x80 → 0x88): Mit 4,49 auf
SurfaceBrush lag es knapp unter den 4,5:1 fuer normalen Text. -->
<SolidColorBrush x:Key="TextMutedBrush" Color="#b3eef0f1" />
<SolidColorBrush x:Key="TextSubtleBrush" Color="#99eef0f1" />
<SolidColorBrush x:Key="TextFaintBrush" Color="#8ceef0f1" />
<SolidColorBrush x:Key="TextGhostBrush" Color="#80eef0f1" />
<SolidColorBrush x:Key="TextGhostBrush" Color="#88eef0f1" />
<SolidColorBrush x:Key="HoverBrush" Color="#12eef0f1" />
<SolidColorBrush x:Key="PressedBrush" Color="#24eef0f1" />
@@ -100,8 +100,12 @@
<DockPanel>
<TextBlock Classes="h2" Text="Vorhandene Sicherungen" VerticalAlignment="Center" />
<!-- Mindestabstand zur Ueberschrift links. Vorher stand hier Margin="0" und
der Zwischenraum entstand nur, wenn das Fenster breit genug war; bei
1600 Punkten reichte er gerade nicht, und "Vorhandene Sicherungen"
beruehrte die Schaltflaeche "Aktualisieren". -->
<WrapPanel DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar"
Margin="0">
Margin="16,0,0,0">
<Button Classes="toolbar" Content="Aktualisieren"
Command="{Binding RefreshBackupListCommand}" />
<Button Classes="toolbar" Content="Ordner öffnen"
@@ -84,12 +84,25 @@
<TextBlock Classes="kicker" Text="MODULE" />
<TextBlock Classes="h2" Text="Baugruppen" />
<!-- Feste Breiten statt Sternanteilen fuer die drei schmalen Spalten.
Sternbreiten teilen den Platz erst auf, NACHDEM die Mindestbreite jedes
Inhalts gedeckt ist. Weil jede Zeile ein eigenes Grid ist, hat ein langer
Aenderungstext seiner Zeile - und nur ihr - den Platz der anderen drei
Spalten weggenommen: Die BUILD-Zahl rutschte unter MODUL, BUILD-DATUM und
AENDERUNGEN stiessen ohne Trennung aneinander. Feste Breiten koennen nicht
zusammengedrueckt werden, also stehen Kopf und alle Zeilen buendig, ganz
gleich, was in der letzten Spalte steht.
Die letzte Spalte bleibt "*": Sie ist die letzte, ein Ueberlauf dort
verschiebt nichts mehr, und ClipToBounds haelt ihn innerhalb der Tabelle.
Der linke Aussenabstand sorgt dafuer, dass Datum und Text auch im
Grenzfall nicht aneinanderstossen. -->
<Border Classes="thead">
<Grid ColumnDefinitions="1.6*,0.6*,1.4*,3*">
<Grid ColumnDefinitions="170,60,150,*" ClipToBounds="True">
<TextBlock Grid.Column="0" Classes="th" Text="MODUL" />
<TextBlock Grid.Column="1" Classes="th" Text="BUILD" />
<TextBlock Grid.Column="2" Classes="th" Text="BUILD-DATUM" />
<TextBlock Grid.Column="3" Classes="th" Text="ÄNDERUNGEN" />
<TextBlock Grid.Column="3" Classes="th" Text="ÄNDERUNGEN" Margin="10,0,0,0" />
</Grid>
</Border>
@@ -97,11 +110,11 @@
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AssemblyInfoItem">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.6*,0.6*,1.4*,3*">
<Grid ColumnDefinitions="170,60,150,*" ClipToBounds="True">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold" Text="{Binding ShortName}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding BuildNumber}" />
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding BuildDate}" />
<TextBlock Grid.Column="3" Classes="td dim" Text="{Binding Changes}" />
<TextBlock Grid.Column="3" Classes="td dim" Text="{Binding Changes}" Margin="10,0,0,0" />
</Grid>
</Border>
</DataTemplate>
@@ -98,8 +98,11 @@ public sealed class AgentCommTool : IAgentTool
"Agent {From} sendet Nachricht an {To}: {Msg}",
context.AgentId, targetId, message.Length > 100 ? message[..100] + "..." : message);
// Die Delegationskette des eigenen Laufs weiterreichen: Nur damit sieht die
// Engine, dass dieser Aufruf zu einer bestehenden Kette gehört, und erkennt
// A -> B -> A als Zyklus statt als neue Beauftragung (B8).
var result = await context.MessageRouter!.SendMessageAsync(
context.AgentId, targetId, message, ct);
context.AgentId, targetId, message, context.InstanceId, context.Delegation, ct);
if (result.Delivered)
{
@@ -100,8 +100,10 @@ public sealed class AgentSpawnTool : IAgentTool
context.AgentId, targetId,
taskMessage.Length > 100 ? taskMessage[..100] + "..." : taskMessage);
// Kette weiterreichen — sonst liesse sich die Tiefenbegrenzung umgehen, indem
// eine Staffel abwechselnd spawn und send_message benutzt (B8).
var result = await context.MessageRouter!.SpawnAgentAsync(
context.AgentId, targetId, taskMessage, ct);
context.AgentId, targetId, taskMessage, context.InstanceId, context.Delegation, ct);
if (result.Started)
{
@@ -81,49 +81,45 @@ public sealed class DirectApiTool : IAgentTool
{
var apiKey = GetApiKey(providers, "twelvedata");
var url = $"https://api.twelvedata.com/quote?symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (json.Value.TryGetProperty("code", out var code) && code.GetInt32() != 200)
return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler");
if (json.TryGetProperty("code", out var code) && code.GetInt32() != 200)
return ToolResult.Fail(json.GetProperty("message").GetString() ?? "API Fehler");
DateTime? dataAsOf = null;
if (json.Value.TryGetProperty("timestamp", out var ts))
if (json.TryGetProperty("timestamp", out var ts))
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value);
return CreateSuccessResult(fetchedAt, dataAsOf, url, json);
}
else if (provider == "alphavantage")
{
var apiKey = GetApiKey(providers, "alphavantage");
var url = $"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (json.Value.TryGetProperty("Note", out var note))
if (json.TryGetProperty("Note", out var note))
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
if (json.Value.TryGetProperty("Error Message", out var err))
if (json.TryGetProperty("Error Message", out var err))
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value);
return CreateSuccessResult(fetchedAt, fetchedAt, url, json);
}
else if (provider == "massive")
{
var apiKey = GetApiKey(providers, "massive");
var url = $"https://api.massive.com/v3/snapshot?ticker={symbol}";
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value);
return CreateSuccessResult(fetchedAt, fetchedAt, url, json);
}
else if (provider == "yahoo")
{
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1m&range=1d";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (!json.Value.TryGetProperty("chart", out var chartProp) ||
if (!json.TryGetProperty("chart", out var chartProp) ||
!chartProp.TryGetProperty("result", out var resultArr) ||
resultArr.GetArrayLength() == 0)
return ToolResult.Fail($"Yahoo Finance: Keine Daten für '{symbol}' erhalten.");
@@ -153,13 +149,12 @@ public sealed class DirectApiTool : IAgentTool
var apiKey = GetApiKey(providers, "twelvedata");
var url = $"https://api.twelvedata.com/time_series?symbol={symbol}&interval={interval}&outputsize={outputSize}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (json.Value.TryGetProperty("status", out var status) && status.GetString() != "ok")
return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler");
if (json.TryGetProperty("status", out var status) && status.GetString() != "ok")
return ToolResult.Fail(json.GetProperty("message").GetString() ?? "API Fehler");
return CreateSuccessResult(fetchedAt, null, url, json.Value);
return CreateSuccessResult(fetchedAt, null, url, json);
}
else if (provider == "alphavantage")
{
@@ -177,15 +172,14 @@ public sealed class DirectApiTool : IAgentTool
var urlBase = $"https://www.alphavantage.co/query?function={function_}&symbol={symbol}&apikey={apiKey}&outputsize=compact";
if (!string.IsNullOrEmpty(avInterval)) urlBase += $"&interval={avInterval}";
var (json, error) = await SafeGetJsonAsync(http, urlBase, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, urlBase, ct)).TryGet(out var json, out var error)) return error;
if (json.Value.TryGetProperty("Note", out var note))
if (json.TryGetProperty("Note", out var note))
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
if (json.Value.TryGetProperty("Error Message", out var err))
if (json.TryGetProperty("Error Message", out var err))
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
return CreateSuccessResult(fetchedAt, null, urlBase, json.Value);
return CreateSuccessResult(fetchedAt, null, urlBase, json);
}
else if (provider == "massive")
{
@@ -204,10 +198,9 @@ public sealed class DirectApiTool : IAgentTool
var url = $"https://api.massive.com/v2/aggs/ticker/{symbol}/range/{multiplier}/{timespan}/{from}/{to}?limit={outputSize}";
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
return CreateSuccessResult(fetchedAt, null, url, json.Value);
return CreateSuccessResult(fetchedAt, null, url, json);
}
else if (provider == "yahoo")
{
@@ -219,10 +212,9 @@ public sealed class DirectApiTool : IAgentTool
var range = interval is "1min" or "5min" or "15min" ? "1d" : $"{outputSize}d";
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval={yahooInterval}&range={range}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (!json.Value.TryGetProperty("chart", out var chartProp) ||
if (!json.TryGetProperty("chart", out var chartProp) ||
!chartProp.TryGetProperty("result", out var resultArr) ||
resultArr.GetArrayLength() == 0)
return ToolResult.Fail($"Yahoo Finance: Keine Historien-Daten für '{symbol}' erhalten.");
@@ -240,10 +232,9 @@ public sealed class DirectApiTool : IAgentTool
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.coingecko.com/api/v3/simple/price?ids={symbol.ToLowerInvariant()}&vs_currencies=usd&include_last_updated_at=true";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
if (!json.Value.TryGetProperty(symbol.ToLowerInvariant(), out var data))
if (!json.TryGetProperty(symbol.ToLowerInvariant(), out var data))
return ToolResult.Fail($"Symbol '{symbol}' nicht gefunden.");
DateTime? dataAsOf = null;
@@ -262,14 +253,13 @@ public sealed class DirectApiTool : IAgentTool
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.twelvedata.com/exchange_rate?symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
DateTime? dataAsOf = null;
if (json.Value.TryGetProperty("timestamp", out var ts))
if (json.TryGetProperty("timestamp", out var ts))
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value);
return CreateSuccessResult(fetchedAt, dataAsOf, url, json);
}
private async Task<ToolResult> SearchSymbolAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
@@ -281,16 +271,54 @@ public sealed class DirectApiTool : IAgentTool
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.twelvedata.com/symbol_search?symbol={symbol}"; // search often doesn't need key or uses same
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
return CreateSuccessResult(fetchedAt, null, url, json.Value);
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
return CreateSuccessResult(fetchedAt, null, url, json);
}
/// <summary>
/// Das Ergebnis eines API-Aufrufs: <b>entweder</b> Daten <b>oder</b> eine Absage —
/// nie beides, nie keines von beiden.
///
/// <para>Vorher war das ein Tupel <c>(JsonElement? json, ToolResult? error)</c>, und
/// jede der elf Aufrufstellen las danach <c>json.Value</c>. Dass der Wert nach
/// <c>if (error != null) return error;</c> immer da ist, wusste nur der Mensch —
/// der Compiler wies mit elf CS8629 darauf hin, dass er es nicht nachvollziehen
/// kann. Elf Warnungen, die man wegzusehen lernt, verdecken die zwölfte, die
/// stimmt.</para>
///
/// <para><see cref="TryGet"/> sagt dasselbe so, dass es nachvollziehbar ist: Wer
/// <c>true</c> bekommt, hat die Daten; wer <c>false</c> bekommt, hat die Absage,
/// die er zurückgeben soll.</para>
/// </summary>
private readonly struct JsonFetch
{
private readonly JsonElement _json;
private readonly ToolResult? _error;
private JsonFetch(JsonElement json, ToolResult? error)
{
_json = json;
_error = error;
}
public static JsonFetch Ok(JsonElement json) => new(json, null);
public static JsonFetch Fail(ToolResult error) => new(default, error);
public bool TryGet(out JsonElement json, out ToolResult error)
{
json = _json;
// Der Ersatz greift nie: Fail verlangt eine Absage, Ok setzt keine. Er steht
// hier, damit die Zusage ohne Ausrufezeichen auskommt.
error = _error ?? ToolResult.Fail("Unbekannter Fehler beim API-Aufruf.");
return _error is null;
}
}
/// <summary>
/// Sichere JSON-Antwort von einer API abrufen.
/// Prüft HTTP-Status und ob die Antwort tatsächlich JSON ist, bevor geparst wird.
/// </summary>
private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync(
private static async Task<JsonFetch> SafeGetJsonAsync(
HttpClient http, string url, CancellationToken ct)
{
// Auch Fehlermeldungen gehen an das Modell — der Schlüssel darf darin nicht auftauchen.
@@ -303,7 +331,7 @@ public sealed class DirectApiTool : IAgentTool
}
catch (HttpRequestException ex)
{
return (null, ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}"));
return JsonFetch.Fail(ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}"));
}
var body = await response.Content.ReadAsStringAsync(ct);
@@ -311,7 +339,7 @@ public sealed class DirectApiTool : IAgentTool
if (!response.IsSuccessStatusCode)
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
return JsonFetch.Fail(ToolResult.Fail(
$"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {safeUrl}\nAntwort: {preview}"));
}
@@ -320,19 +348,19 @@ public sealed class DirectApiTool : IAgentTool
if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
return JsonFetch.Fail(ToolResult.Fail(
$"API hat kein JSON zurückgegeben ({safeUrl}). Antwort: {preview}"));
}
try
{
var json = JsonDocument.Parse(body).RootElement.Clone();
return (json, null);
return JsonFetch.Ok(json);
}
catch (JsonException ex)
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
return JsonFetch.Fail(ToolResult.Fail(
$"Ungültiges JSON von {safeUrl}: {ex.Message}\nAntwort: {preview}"));
}
}
+10 -12
View File
@@ -521,6 +521,8 @@ public sealed class FileRWTool : IAgentTool
["agent"] = context.AgentId
};
int indexCount;
var indexLock = GetFileLock(indexPath);
await indexLock.WaitAsync(ct);
try
@@ -552,6 +554,13 @@ public sealed class FileRWTool : IAgentTool
// eine halb geschriebene Datei wäre nicht wiederherstellbar.
await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(
indexPath, JsonSerializer.Serialize(index, StockJsonOpts), ct: ct);
// Den Stand hier festhalten, solange die Sperre noch gilt (B9). Vorher
// wurde die Datei nach dem Freigeben ein zweites Mal gelesen und geparst:
// ein paralleler stock_add konnte dazwischenschreiben, und die gemeldete
// Zahl gehörte dann zu einem anderen Aufruf als die gemeldete Datei.
// Innerhalb der Sperre ist der Zähler ohnehin bekannt.
indexCount = index.Count;
}
finally
{
@@ -563,18 +572,7 @@ public sealed class FileRWTool : IAgentTool
$"✅ Datenpunkt hinzugefügt: {relPath}\n" +
$"Ticker: {ticker} | Kategorie: {category} | Quelle: {source}\n" +
$"Titel: {title}\n" +
$"Index: {index_Count(indexPath)} Einträge für {ticker}");
}
private static int index_Count(string indexPath)
{
try
{
var json = File.ReadAllText(indexPath);
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetArrayLength();
}
catch { return -1; }
$"Index: {indexCount} Einträge für {ticker}");
}
private static string SanitizeFileName(string name)
+16 -8
View File
@@ -31,23 +31,31 @@ public static class WorkspacePath
var value = string.IsNullOrWhiteSpace(relativePath) ? "." : relativePath.Trim();
// Absolute Pfade und UNC-Freigaben würden Path.Combine den Root verwerfen lassen.
if (Path.IsPathRooted(value) || value.StartsWith(@"\\") || value.StartsWith("//"))
if (Path.IsPathRooted(value) || value.StartsWith("//"))
{
throw new UnauthorizedAccessException(
$"Zugriff verweigert: Absolute Pfade sind im {workspaceName} Workspace nicht erlaubt.");
}
// Alternate Data Streams (datei.txt:versteckt) umgehen die Endungsprüfung.
// Doppelpunkt und Backslash werden rundheraus abgewiesen.
//
// Die Prüfung trägt unter Linux zusätzlich: Dort meldet Path.IsPathRooted für
// "C:\Windows\…" false — ein Windows-Pfad ist unter Linux ein gewöhnlicher
// Dateiname. Ohne diese Zeile käme er als relativer Pfad durch. Ein Doppelpunkt
// im Dateinamen ist unter Linux zwar erlaubt, aber nichts, was ein Agent
// Beide sind unter Linux gewöhnliche Namenszeichen, und genau daran scheiterte
// die Abwehr der Windows-Formen: Path.IsPathRooted(@"C:\temp\x") meldet dort
// false, und @"..\..\evil.txt" ist ein einzelner Dateiname statt eines
// Ausbruchs. Ein Ausbruch war das nie — die Datei blieb im Workspace —, aber
// eine Sonderregel je Windows-Form (Doppelpunkt für Laufwerke, "\\" für UNC)
// lässt immer eine Form übrig, die durchfällt. Hier gilt darum eine
// Schreibweise: "/" trennt Abschnitte, alles andere ist ein Name. Beide Zeichen
// sind unter Linux in Dateinamen zwar erlaubt, aber nichts, was ein Agent
// braucht; die Einschränkung kostet uns nichts.
if (value.Contains(':'))
//
// Der Doppelpunkt fängt nebenbei Alternate Data Streams (datei.txt:versteckt),
// die unter Windows die Endungsprüfung umgehen.
if (value.Contains(':') || value.Contains('\\'))
{
throw new UnauthorizedAccessException(
$"Zugriff verweigert: Der Pfad enthält ein unzulässiges Zeichen (':').");
@"Zugriff verweigert: Der Pfad enthält ein unzulässiges Zeichen (':' oder '\'). " +
"Abschnitte werden mit '/' getrennt.");
}
var normalizedRoot = NormalizeDirectory(root);
+77 -15
View File
@@ -80,8 +80,8 @@ public sealed class MailTool : IAgentTool, IToolJobProvider
private async Task<ToolResult> HandleSendAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
var to = input.TryGetProperty("to", out var t) ? t.GetString() : null;
var subject = input.TryGetProperty("subject", out var s) ? s.GetString() : "(Kein Betreff)";
var body = input.TryGetProperty("body", out var b) ? b.GetString() : "";
var subject = (input.TryGetProperty("subject", out var s) ? s.GetString() : null) ?? "(Kein Betreff)";
var body = (input.TryGetProperty("body", out var b) ? b.GetString() : null) ?? "";
if (string.IsNullOrWhiteSpace(to)) return ToolResult.Fail("'to' ist erforderlich.");
@@ -91,16 +91,18 @@ public sealed class MailTool : IAgentTool, IToolJobProvider
return ToolResult.Fail($"Sicherheitsfehler: Senden an '{to}' ist nicht erlaubt.");
}
var config = context.ToolConfig;
if (!TryReadAccess(context, "smtpHost", "smtpPort", 587, out var access, out var fehlt))
return ToolResult.Fail(fehlt);
var message = new MimeMessage();
message.From.Add(new MailboxAddress("ClawdDotNet", config["username"]?.ToString()));
message.From.Add(new MailboxAddress("ClawdDotNet", access.UserName));
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
message.Body = new TextPart("plain") { Text = body };
using var client = new SmtpClient();
await client.ConnectAsync(config["smtpHost"]?.ToString(), (config["smtpPort"] is JsonElement sp ? sp.GetInt32() : 587), true, ct);
await client.AuthenticateAsync(config["username"]?.ToString(), config["password"]?.ToString(), ct);
await client.ConnectAsync(access.Host, access.Port, true, ct);
await client.AuthenticateAsync(access.UserName, access.Password, ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
@@ -110,11 +112,13 @@ public sealed class MailTool : IAgentTool, IToolJobProvider
private async Task<ToolResult> HandleReadInboxAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
var limit = input.TryGetProperty("limit", out var l) ? l.GetInt32() : 10;
var config = context.ToolConfig;
if (!TryReadAccess(context, "imapHost", "imapPort", 993, out var access, out var fehlt))
return ToolResult.Fail(fehlt);
using var client = new ImapClient();
await client.ConnectAsync(config["imapHost"]?.ToString(), (config["imapPort"] is JsonElement ip ? ip.GetInt32() : 993), true, ct);
await client.AuthenticateAsync(config["username"]?.ToString(), config["password"]?.ToString(), ct);
await client.ConnectAsync(access.Host, access.Port, true, ct);
await client.AuthenticateAsync(access.UserName, access.Password, ct);
await client.Inbox!.OpenAsync(FolderAccess.ReadOnly, ct);
var messages = new List<object>();
@@ -142,10 +146,12 @@ public sealed class MailTool : IAgentTool, IToolJobProvider
return ToolResult.Fail("'messageId' (Index) ist erforderlich.");
}
var config = context.ToolConfig;
if (!TryReadAccess(context, "imapHost", "imapPort", 993, out var access, out var fehlt))
return ToolResult.Fail(fehlt);
using var client = new ImapClient();
await client.ConnectAsync(config["imapHost"]?.ToString(), (config["imapPort"] is JsonElement ip ? ip.GetInt32() : 993), true, ct);
await client.AuthenticateAsync(config["username"]?.ToString(), config["password"]?.ToString(), ct);
await client.ConnectAsync(access.Host, access.Port, true, ct);
await client.AuthenticateAsync(access.UserName, access.Password, ct);
await client.Inbox!.OpenAsync(FolderAccess.ReadOnly, ct);
var msg = await client.Inbox!.GetMessageAsync(index, ct);
@@ -170,10 +176,12 @@ public sealed class MailTool : IAgentTool, IToolJobProvider
return ToolResult.Fail("'messageId' (Index) ist erforderlich.");
}
var config = context.ToolConfig;
if (!TryReadAccess(context, "imapHost", "imapPort", 993, out var access, out var fehlt))
return ToolResult.Fail(fehlt);
using var client = new ImapClient();
await client.ConnectAsync(config["imapHost"]?.ToString(), (config["imapPort"] is JsonElement ip ? ip.GetInt32() : 993), true, ct);
await client.AuthenticateAsync(config["username"]?.ToString(), config["password"]?.ToString(), ct);
await client.ConnectAsync(access.Host, access.Port, true, ct);
await client.AuthenticateAsync(access.UserName, access.Password, ct);
await client.Inbox!.OpenAsync(FolderAccess.ReadWrite, ct);
await client.Inbox!.AddFlagsAsync(index, MessageFlags.Seen, true, ct);
@@ -182,6 +190,60 @@ public sealed class MailTool : IAgentTool, IToolJobProvider
return ToolResult.Ok($"Nachricht {index} als gelesen markiert.");
}
/// <summary>Ein Zugang: Server, Port und Anmeldedaten — vollständig oder gar nicht.</summary>
private readonly record struct MailAccess(string Host, int Port, string UserName, string Password);
/// <summary>
/// Liest Server und Anmeldedaten aus der Werkzeug-Konfiguration.
///
/// <para>Vorher standen die Werte an vier Stellen einzeln im Aufruf:
/// <c>config["smtpHost"]?.ToString()</c>. Das ging auf zwei Arten schief. Fehlte der
/// Schlüssel ganz, warf der Indexer eine <c>KeyNotFoundException</c>; war er leer,
/// ging <c>null</c> an MailKit — worauf der Compiler mit fünfzehn
/// Nullable-Warnungen hinwies. Beim Agenten kam beides als dieselbe unbrauchbare
/// Zeile an: „Fehler: …".</para>
///
/// <para>Hier fällt stattdessen die Antwort aus, die weiterhilft: welcher Schlüssel
/// fehlt. Der Port bleibt weich — dafür gibt es sinnvolle Vorgaben; Server und
/// Anmeldedaten kann niemand raten.</para>
/// </summary>
private static bool TryReadAccess(
AgentToolContext context, string hostKey, string portKey, int defaultPort,
out MailAccess access, out string error)
{
access = default;
var host = ReadSetting(context, hostKey);
var user = ReadSetting(context, "username");
var password = ReadSetting(context, "password");
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(host)) missing.Add(hostKey);
if (string.IsNullOrWhiteSpace(user)) missing.Add("username");
if (string.IsNullOrWhiteSpace(password)) missing.Add("password");
if (missing.Count > 0)
{
error = "Das Mail-Werkzeug ist nicht vollständig eingerichtet. " +
$"Es fehlt in der Werkzeug-Konfiguration: {string.Join(", ", missing)}.";
return false;
}
access = new MailAccess(host!, ReadPort(context, portKey, defaultPort), user!, password!);
error = "";
return true;
}
private static string? ReadSetting(AgentToolContext context, string key)
=> context.ToolConfig.TryGetValue(key, out var value) ? value?.ToString() : null;
private static int ReadPort(AgentToolContext context, string key, int fallback)
=> context.ToolConfig.TryGetValue(key, out var value)
&& value is JsonElement { ValueKind: JsonValueKind.Number } je
&& je.TryGetInt32(out var port)
? port
: fallback;
private bool IsRecipientAllowed(string email, AgentToolContext context)
{
if (!context.ToolConfig.TryGetValue("allowedRecipients", out var val) || val is not JsonElement je)
+20 -5
View File
@@ -83,7 +83,13 @@ public sealed class WebFetchTool : IAgentTool
if (redirectError is not null)
return redirectError;
using var _ = response!;
// response ist genau dann gesetzt, wenn keine Absage kam - dem Tupel sieht der
// Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt
// das Ausrufezeichen, das hier stand.
if (response is null)
return ToolResult.Fail($"Keine Antwort von {url}.");
using var _ = response;
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
@@ -119,7 +125,13 @@ public sealed class WebFetchTool : IAgentTool
if (redirectError is not null)
return redirectError;
using var _ = response!;
// response ist genau dann gesetzt, wenn keine Absage kam - dem Tupel sieht der
// Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt
// das Ausrufezeichen, das hier stand.
if (response is null)
return ToolResult.Fail($"Keine Antwort von {url}.");
using var _ = response;
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"RSS-Feed nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
@@ -128,8 +140,11 @@ public sealed class WebFetchTool : IAgentTool
var doc = XDocument.Parse(xml);
var items = new List<object>();
// Support RSS and Atom
var ns = doc.Root?.Name.Namespace;
// Support RSS and Atom.
// Ohne Wurzelelement gibt es keinen Namensraum — dann gilt der leere. Vorher
// stand das Ausweichen nur an der ersten von fünf Stellen, die übrigen vier
// rechneten mit einem möglicherweise fehlenden Namensraum weiter.
var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
if (doc.Root?.Name.LocalName == "rss")
{
@@ -146,7 +161,7 @@ public sealed class WebFetchTool : IAgentTool
}
else // Atom
{
foreach (var entry in doc.Descendants((ns ?? XNamespace.None) + "entry").Take(20))
foreach (var entry in doc.Descendants(ns + "entry").Take(20))
{
items.Add(new
{
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.App.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClawdDotNet.App\ClawdDotNet.App.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,97 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.App.Tests;
/// <summary>
/// #375: Die Einstellungsseite zeigte ein Log-Verzeichnis, das nie galt.
///
/// <c>InstanceConfig.LogDirectory</c> war mit <c>"./Logs"</c> vorbelegt und damit nie
/// leer — der Zweig auf die Anwendungseinstellung war unerreichbar. Aufgelöst wurde der
/// Wert zudem mit <c>Path.GetFullPath</c> ohne Basis, also gegen das Arbeitsverzeichnis
/// des Prozesses: Wo die Logs landeten, hing davon ab, aus welchem Verzeichnis jemand
/// gestartet hatte. Für den Zielbetrieb (systemd-Dienst, startet in <c>/</c>) war das
/// <c>/Logs</c> — für ein Dienstkonto nicht anlegbar.
/// </summary>
public sealed class LogDirectoryResolutionTests
{
private static readonly string InstancePath =
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-Test"));
private static readonly string ApplicationLogDirectory =
Path.Combine(AppPaths.DataDirectory, "Logs");
private static InstanceConfig Instance(string logDirectory, string? workingDirectory = null)
=> new()
{
LogDirectory = logDirectory,
WorkingDirectory = workingDirectory ?? InstancePath
};
[Fact]
public void Die_Vorbelegung_ist_leer()
{
// Der Kern des Befunds: Solange hier "./Logs" stand, konnte die
// Anwendungseinstellung nicht greifen.
new InstanceConfig().LogDirectory.ShouldBe("");
}
[Fact]
public void Ohne_Angabe_in_der_Instanz_gilt_die_Anwendungseinstellung()
{
AppHost.ResolveLogDirectory(Instance(""), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
}
[Fact]
public void Ein_absoluter_Wert_in_der_Instanz_geht_vor()
{
var eigen = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs"));
AppHost.ResolveLogDirectory(Instance(eigen), ApplicationLogDirectory, InstancePath)
.ShouldBe(eigen);
}
[Fact]
public void Ein_relativer_Wert_zaehlt_gegen_das_Instanzverzeichnis()
{
// Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler.
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Ein_relativer_Wert_haengt_nicht_am_Arbeitsverzeichnis_des_Prozesses()
{
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
.ShouldNotBe(Path.GetFullPath("./Logs"));
}
[Fact]
public void Ein_leeres_WorkingDirectory_faellt_auf_den_Instanzpfad_zurueck()
{
// Das Feld steht seit CreateInstance absolut in der Datei, kann aber von Hand
// geleert worden sein. Dann trägt der Ort, aus dem geladen wurde.
AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: ""), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Ein_relatives_WorkingDirectory_faellt_ebenfalls_auf_den_Instanzpfad_zurueck()
{
// "./data/" ist die alte Vorbelegung des Feldes und wäre wieder gegen das
// Arbeitsverzeichnis des Prozesses aufgelöst worden.
AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: "./data/"), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Nur_Leerzeichen_gelten_als_keine_Angabe()
{
AppHost.ResolveLogDirectory(Instance(" "), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
}
}
@@ -0,0 +1,88 @@
using System.Text.Json;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Die andere Hälfte von #375: Bestehende Instanzen tragen <c>"logDirectory": "./Logs"</c>
/// in ihrer <c>InstanceSettings.json</c> — <see cref="InstanceDirectoryManager.CreateInstance"/>
/// hat es dort hineingeschrieben. Der Wert hatte nie die Bedeutung, die er vorgab, und
/// wird beim Laden zu „leer", also zur Anwendungseinstellung.
///
/// Wer bewusst einen anderen Pfad eingetragen hat, behält ihn.
/// </summary>
public sealed class InstanceLogDirectoryMigrationTests : IDisposable
{
private readonly string _instancesDir;
private readonly InstanceDirectoryManager _manager;
public InstanceLogDirectoryMigrationTests()
{
_instancesDir = Path.Combine(
Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Instances");
_manager = new InstanceDirectoryManager(_instancesDir);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_instancesDir)!, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
/// <summary>Legt eine Instanz an und setzt logDirectory in der Datei auf den Altwert.</summary>
private string InstanceWithLogDirectory(string value)
{
var dir = _manager.CreateInstance("Test");
var path = Path.Combine(dir, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(path))!;
config.LogDirectory = value;
File.WriteAllText(path, JsonSerializer.Serialize(config));
return dir;
}
[Theory]
[InlineData("./Logs")]
[InlineData(@".\Logs")]
[InlineData("Logs")]
public void Die_alte_Vorbelegung_wird_zu_keiner_Angabe(string alt)
{
var dir = InstanceWithLogDirectory(alt);
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe("");
}
[Fact]
public void Ein_bewusst_gesetzter_Pfad_bleibt_stehen()
{
var eigen = Path.Combine(Path.GetTempPath(), "clawd-eigene-logs");
var dir = InstanceWithLogDirectory(eigen);
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe(eigen);
}
[Fact]
public void Die_Datei_wird_beim_Laden_nicht_zurueckgeschrieben()
{
// Die Migration greift nur im Speicher. Ein Ladevorgang darf nichts anfassen —
// erst das nächste Speichern lässt den Altwert verschwinden.
var dir = InstanceWithLogDirectory("./Logs");
var path = Path.Combine(dir, "InstanceSettings.json");
_manager.LoadInstanceConfig(dir);
File.ReadAllText(path).ShouldContain("./Logs");
}
[Fact]
public void Eine_neue_Instanz_bekommt_gar_keinen_Wert()
{
var dir = _manager.CreateInstance("Frisch");
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe("");
File.ReadAllText(Path.Combine(dir, "InstanceSettings.json")).ShouldNotContain("./Logs");
}
}
@@ -0,0 +1,200 @@
using ClawdDotNet.App.Services;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #376: Jedes Modul schreibt in seine eigene Datei, und eingesammelt wurde Datei für
/// Datei — also alphabetisch nach Modulname. In der Log-Ansicht standen die Zeilen
/// damit in Blöcken je Modul statt in der Reihenfolge, in der sie entstanden sind.
/// Beobachtet wurde eine Zeile von 14:34:40 über vier Zeilen von 14:34:39.
///
/// Das Dateisystem wird hier nicht abstrahiert: <see cref="LogTail"/> ist genau der
/// Umgang mit echten, nebenher wachsenden Dateien — eine Abstraktion würde das
/// Verhalten verstecken, um das es geht.
/// </summary>
public sealed class LogTailTests : IDisposable
{
private readonly string _logDirectory;
private readonly string _today;
public LogTailTests()
{
_logDirectory = Path.Combine(
Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Logs");
// LogTail liest ausschließlich das Verzeichnis des heutigen Tages, und zwar
// nach Ortszeit — die Schreibseite legt es genauso an.
_today = Path.Combine(_logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
Directory.CreateDirectory(_today);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_logDirectory)!, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private void Write(string module, params string[] lines)
=> File.AppendAllLines(Path.Combine(_today, $"{module}.log"), lines);
private LogTail Tail() => new(_logDirectory);
// ═══════════════════════════════════════════════════════════
// Reihenfolge über Moduldateien hinweg
// ═══════════════════════════════════════════════════════════
[Fact]
public void Zeilen_aus_mehreren_Modulen_stehen_in_zeitlicher_Reihenfolge()
{
// Genau der beobachtete Fall: "Deploymentcenter" steht alphabetisch vor
// "Startup", seine Zeile ist aber die jüngste von allen.
Write("Deploymentcenter", "[14:34:40.246] [INF] kein Token hinterlegt");
Write("Startup",
"[14:34:39.840] [INF] ClawdDotNet startet",
"[14:34:39.871] [INF] Instanz-Verzeichnis",
"[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[14:34:39.840] [INF] ClawdDotNet startet",
"[14:34:39.871] [INF] Instanz-Verzeichnis",
"[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert",
"[14:34:40.246] [INF] kein Token hinterlegt"
]);
}
[Fact]
public void Bei_gleichem_Zeitstempel_bleibt_die_bisherige_Ordnung()
{
// Stabil sortiert: Zwei Zeilen derselben Millisekunde dürfen nicht die Plätze
// tauschen, sonst zerfällt ein zusammenhängender Ablauf innerhalb eines Moduls.
//
// Über Moduldateien hinweg entscheidet dann der Modulname — "Alpha" vor "Beta".
// Directory.GetFiles liefert unter Linux die Verzeichnisreihenfolge; ohne die
// Sortierung in ReadNew stünde hier von Lauf zu Lauf etwas anderes.
Write("Alpha", "[10:00:00.000] [INF] alpha eins", "[10:00:00.000] [INF] alpha zwei");
Write("Beta", "[10:00:00.000] [INF] beta eins");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.000] [INF] alpha eins",
"[10:00:00.000] [INF] alpha zwei",
"[10:00:00.000] [INF] beta eins"
]);
}
[Fact]
public void Ein_Stacktrace_bleibt_bei_seiner_Meldung()
{
// Eine Ausnahme hängt ihre Zeilen ohne eigenen Zeitstempel unter die Meldung.
// Ohne Sonderbehandlung wären sie ohne Schlüssel nach ganz vorn gerutscht.
Write("Alpha", "[10:00:00.500] [INF] danach");
Write("Engine",
"[10:00:00.100] [ERR] Aufruf fehlgeschlagen",
"System.InvalidOperationException: kaputt",
" bei ClawdDotNet.Engine.Run()");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.100] [ERR] Aufruf fehlgeschlagen",
"System.InvalidOperationException: kaputt",
" bei ClawdDotNet.Engine.Run()",
"[10:00:00.500] [INF] danach"
]);
}
[Fact]
public void Ein_Stacktrace_bleibt_auch_ueber_die_Taktgrenze_bei_seiner_Meldung()
{
// Der Takt kann mitten in einem Eintrag enden: Die Meldung ist gelesen, die
// Fortsetzung kommt erst beim nächsten Aufruf. Der Zeitstempel muss dann aus
// dem vorigen Takt weitergetragen werden.
var tail = Tail();
Write("Engine", "[10:00:00.100] [ERR] Aufruf fehlgeschlagen");
tail.ReadNew();
Write("Alpha", "[10:00:00.500] [INF] danach");
Write("Engine", " bei ClawdDotNet.Engine.Run()");
var lines = tail.ReadNew();
lines.Select(l => l.Text).ShouldBe([
" bei ClawdDotNet.Engine.Run()",
"[10:00:00.500] [INF] danach"
]);
}
[Fact]
public void Eine_Uhrzeit_mitten_in_der_Meldung_ist_kein_Sortierschluessel()
{
// Gesucht wird der Zeitstempel am Zeilenanfang, nicht irgendeine Uhrzeit im Text.
Write("Alpha", "[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]");
Write("Beta", "[10:00:00.200] [INF] danach");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]",
"[10:00:00.200] [INF] danach"
]);
}
// ═══════════════════════════════════════════════════════════
// Was die Sortierung nicht kaputtmachen darf
// ═══════════════════════════════════════════════════════════
[Fact]
public void Nur_das_Neue_kommt_zurueck()
{
var tail = Tail();
Write("Alpha", "[10:00:00.100] [INF] erste");
tail.ReadNew().Count.ShouldBe(1);
Write("Alpha", "[10:00:00.200] [INF] zweite");
tail.ReadNew().Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] zweite"]);
}
[Fact]
public void Der_Modulfilter_laesst_nur_sein_Modul_durch()
{
Write("Alpha", "[10:00:00.100] [INF] alpha");
Write("Beta", "[10:00:00.200] [INF] beta");
var lines = Tail().ReadNew(module: "Beta");
lines.Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] beta"]);
}
[Fact]
public void Eine_weggefilterte_Zeile_traegt_ihren_Zeitstempel_trotzdem_weiter()
{
// Der Stufenfilter darf die Zuordnung der Fortsetzungszeilen nicht verlieren:
// Die Meldung fällt weg, ihr Stacktrace nicht — er hat keine eigene Stufe und
// wird bewusst durchgelassen. Er muss dann an ihrer Stelle stehen.
Write("Alpha", "[10:00:00.500] [ERR] spaeter Fehler");
Write("Engine",
"[10:00:00.100] [INF] harmlos",
" Fortsetzung ohne Stufe");
var lines = Tail().ReadNew(level: LogLevelFilter.Error);
lines.Select(l => l.Text).ShouldBe([
" Fortsetzung ohne Stufe",
"[10:00:00.500] [ERR] spaeter Fehler"
]);
}
[Fact]
public void Ohne_Tagesverzeichnis_kommt_eine_leere_Liste()
{
Directory.Delete(_today, recursive: true);
Tail().ReadNew().ShouldBeEmpty();
}
}
@@ -89,7 +89,7 @@ public sealed class BudgetGuardTests : IDisposable
status.IsAllowed.ShouldBeFalse();
status.Limit.ShouldBe(BudgetLimit.AgentCost);
status.Reason.ShouldContain("5");
status.Reason.ShouldNotBeNull().ShouldContain("5");
}
// ═══════════════════════════════════════════════════════════
@@ -120,7 +120,7 @@ public sealed class BudgetGuardTests : IDisposable
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeFalse();
status.Reason.ShouldContain("keine Preise");
status.Reason.ShouldNotBeNull().ShouldContain("keine Preise");
}
// ═══════════════════════════════════════════════════════════
@@ -38,9 +38,9 @@ public sealed class ErrorReporterTests
sent.ShouldBeTrue();
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/errors/v1/report");
handler.LastBody!.ShouldContain("\"exception\":\"System.InvalidOperationException\"");
handler.LastBody.ShouldContain("\"message\":\"Kaputt\"");
handler.LastBody.ShouldContain("\"level\":\"fatal\"");
handler.LastBody.ShouldContain("\"project_slug\":\"clawddotnet\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"message\":\"Kaputt\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"level\":\"fatal\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"project_slug\":\"clawddotnet\"");
}
/// <summary>
@@ -101,7 +101,7 @@ public sealed class InstanceHealthProviderTests
var health = await provider.GetAsync(default);
health.Message.ShouldContain("Produktion");
health.Message.ShouldNotBeNull().ShouldContain("Produktion");
health.Metrics.Keys.ShouldNotContain("instanceName");
}
}
@@ -23,7 +23,7 @@ public sealed class TokenProvisionerTests
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/tokens/v1/provision");
handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer master");
handler.LastBody!.ShouldContain("\"instance_id\":\"inst-1\"");
handler.LastBody.ShouldContain("watchdog:ping");
handler.LastBody.ShouldNotBeNull().ShouldContain("watchdog:ping");
token.Token.ShouldBe("dc_sub_abc");
token.TokenId.ShouldBe("tok_s_1");
@@ -30,10 +30,10 @@ public sealed class WatchdogClientTests
handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer tok");
handler.LastBody.ShouldNotBeNull();
handler.LastBody.ShouldContain("\"source\":\"clawddotnet\"");
handler.LastBody.ShouldContain("\"instance\":\"inst-1\"");
handler.LastBody.ShouldContain("\"type\":\"heartbeat\"");
handler.LastBody.ShouldContain("\"interval\":60");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"source\":\"clawddotnet\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"instance\":\"inst-1\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"type\":\"heartbeat\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"interval\":60");
}
/// <summary>
@@ -84,7 +84,7 @@ public sealed class WatchdogClientTests
60, default);
handler.LastBody!.ShouldContain("\"agentCount\":3");
handler.LastBody.ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}");
}
/// <summary>Leere Angaben bleiben weg, statt als leeres Objekt zu erscheinen.</summary>
@@ -96,7 +96,7 @@ public sealed class WatchdogClientTests
await Client(handler).SendHeartbeatAsync(Health(), 60, default);
handler.LastBody!.ShouldNotContain("\"checks\"");
handler.LastBody.ShouldNotContain("\"metrics\"");
handler.LastBody.ShouldNotBeNull().ShouldNotContain("\"metrics\"");
}
[Fact]
@@ -64,7 +64,7 @@ public sealed class WatchdogHeartbeatServiceTests
var last = client.Heartbeats[^1];
last.Status.ShouldBe(WatchdogStatus.Maintenance);
last.Message.ShouldContain("0.1.1");
last.Message.ShouldNotBeNull().ShouldContain("0.1.1");
client.Events[^1].Kind.ShouldBe(WatchdogEventKind.MaintenanceStart);
}
@@ -37,7 +37,7 @@ public sealed class ContextCompactorPropertyTests
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung des Verlaufs.");
var compactor = new ContextCompactor(client, TestLogging.Factory);
compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default)
compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default)
.GetAwaiter().GetResult();
var valid = ContextInvariants.IsValid(messages);
@@ -55,7 +55,7 @@ public sealed class ContextCompactorPropertyTests
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung.");
var compactor = new ContextCompactor(client, TestLogging.Factory);
compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default)
compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default)
.GetAwaiter().GetResult();
var systemCount = messages.Count(m => m.Role == "system");
@@ -59,7 +59,7 @@ public sealed class ContextCompactorTests
ContextInvariants.AssertValid(messages); // Vorbedingung: Ausgangslage ist gültig
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
}
@@ -77,7 +77,7 @@ public sealed class ContextCompactorTests
.ToolCycle(count: 3, resultLength: 300)
.Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
@@ -107,7 +107,7 @@ public sealed class ContextCompactorTests
var (compactor, _) = CreateCompactor();
var messages = Conversation.Start(systemPrompt).Repeat(6).Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
messages[0].Role.ShouldBe("system");
messages[0].Content.ShouldBe(systemPrompt);
@@ -120,7 +120,7 @@ public sealed class ContextCompactorTests
var (compactor, _) = CreateCompactor();
var messages = Conversation.Start(systemPrompt: null).Repeat(6).Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
messages.ShouldNotContain(m => m.Role == "system");
}
@@ -137,7 +137,7 @@ public sealed class ContextCompactorTests
var messages = Conversation.Start().Repeat(6).Build();
var before = messages.Count;
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
messages.Count.ShouldBe(before, "bei fehlgeschlagener Zusammenfassung darf nichts verloren gehen");
@@ -151,7 +151,7 @@ public sealed class ContextCompactorTests
var messages = Conversation.Start().Repeat(6).Build();
var before = messages.Count;
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
messages.Count.ShouldBe(before);
@@ -169,7 +169,7 @@ public sealed class ContextCompactorTests
.Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 8_000)
.Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
messages.Where(m => m.Role == "tool")
@@ -185,7 +185,7 @@ public sealed class ContextCompactorTests
.Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 100)
.Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
foreach (var content in messages.Where(m => m.Role == "tool").Select(m => m.Content))
(content ?? "").ShouldNotContain("gekürzt", Case.Insensitive);
@@ -217,7 +217,7 @@ public sealed class ContextCompactorTests
var (compactor, client) = CreateCompactor();
var messages = Conversation.Start().Repeat(2).Build();
var compacted = await compactor.CompactIfNeededAsync(messages, 10, NeverCompact, Model, default);
var compacted = await compactor.CompactIfNeededAsync(messages, NeverCompact, Model, default);
compacted.ShouldBeFalse();
client.CallCount.ShouldBe(0);
@@ -230,7 +230,7 @@ public sealed class ContextCompactorTests
var messages = Conversation.Start().Repeat(15).Build();
var before = messages.Count;
var compacted = await compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default);
var compacted = await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
compacted.ShouldBeTrue();
messages.Count.ShouldBeLessThan(before);
@@ -0,0 +1,307 @@
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// Test A13 der Teststrategie — Regressionstests für B8 (#206).
///
/// <para><c>send_message</c> ist ein synchroner Aufruf: A hält sein Gate, während es
/// auf B wartet. Rief B nun A, wartete B auf ein Gate, das A erst freigibt, wenn B
/// geantwortet hat. Beide standen, bis der Timeout des äußeren Laufs sie auflöste.</para>
///
/// <para>Die Tests prüfen die drei Zusagen aus <see cref="DelegationChain"/>: kein
/// Agent zweimal in einer Kette, höchstens <c>MaxDelegationDepth</c> Weitergaben, und
/// die Kette gehört zum Aufruf statt zum Agenten.</para>
///
/// <para>Ein Zeitlimit steht bewusst an jedem Test, der verklemmen könnte: Ohne die
/// Prüfung liefe er nicht rot, sondern gar nicht mehr weiter.</para>
/// </summary>
public sealed class DelegationDepthTests
{
private static readonly TimeSpan Frist = TimeSpan.FromSeconds(20);
/// <summary>Wartet auf den Lauf, scheitert aber statt zu hängen.</summary>
private static async Task<AgentRunResult> MitFrist(Task<AgentRunResult> lauf)
{
var fertig = await Task.WhenAny(lauf, Task.Delay(Frist));
fertig.ShouldBe(lauf, "Der Lauf ist nicht zurückgekommen — das ist die Verklemmung aus B8.");
return await lauf;
}
// ═══════════════════════════════════════════════════════════
// A13 — der gemeldete Fall
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task A_nach_B_nach_A_wird_abgebrochen_statt_zu_verklemmen()
{
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-a" // zurück zu A — der Zyklus
});
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
var result = await MitFrist(
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
result.Status.ShouldBe(AgentRunStatus.Completed);
// Der Rückweg B → A ist abgelehnt worden, der Hinweg A → B nicht.
var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" });
rueckweg.Delivered.ShouldBeFalse();
rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus");
rueckweg.Error.ShouldNotBeNull().ShouldContain("agent-a -> agent-b -> agent-a");
tool.Handoffs.Single(h => h is { From: "agent-a", To: "agent-b" }).Delivered.ShouldBeTrue();
}
[Fact]
public async Task Die_Ablehnung_kommt_als_Ergebnis_zurueck_nicht_als_Wurf()
{
// Der abgewiesene Agent soll weiterarbeiten können — er bekommt einen Grund,
// keinen Abbruch. Sonst führte die Zyklusprüfung nur zu einem anderen Ausfall.
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-a"
});
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
var result = await MitFrist(
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
result.Status.ShouldBe(AgentRunStatus.Completed);
result.FinalMessage.ShouldBe("Fertig");
}
// ═══════════════════════════════════════════════════════════
// Die Staffel, die keinen Zyklus bildet
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Eine_Staffel_endet_an_der_Tiefengrenze()
{
// A → B → C → D wäre die Tiefe 3. Mit MaxDelegationDepth = 2 ist bei C Schluss.
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-c",
["agent-c"] = "agent-d"
});
var fixture = new EngineFixture().WithTool(tool);
fixture.Engine.MaxDelegationDepth = 2;
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.AddAgent("agent-c", "TestTool");
fixture.AddAgent("agent-d", "TestTool");
fixture.Client.RespondsContextually();
await MitFrist(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
tool.Handoffs.Single(h => h.To == "agent-b").Delivered.ShouldBeTrue();
tool.Handoffs.Single(h => h.To == "agent-c").Delivered.ShouldBeTrue();
var zuTief = tool.Handoffs.Single(h => h.To == "agent-d");
zuTief.Delivered.ShouldBeFalse();
zuTief.Error.ShouldNotBeNull().ShouldContain("Delegationstiefe");
}
[Fact]
public async Task Innerhalb_der_Grenze_geht_die_Staffel_durch()
{
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-c"
});
var fixture = new EngineFixture().WithTool(tool);
fixture.Engine.MaxDelegationDepth = 2;
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.AddAgent("agent-c", "TestTool");
fixture.Client.RespondsContextually();
var result = await MitFrist(
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
result.Status.ShouldBe(AgentRunStatus.Completed);
tool.Handoffs.ShouldAllBe(h => h.Delivered);
}
// ═══════════════════════════════════════════════════════════
// Die Kette gehört zum Aufruf, nicht zum Agenten
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Zwei_Laeufe_nacheinander_erben_die_Kette_des_anderen_nicht()
{
// Sonst könnte ein Agent nach einer tiefen Kette dauerhaft „verbraucht" sein.
var tool = new DelegatingTool(new Dictionary<string, string> { ["agent-a"] = "agent-b" });
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
await MitFrist(fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default));
await MitFrist(fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default));
tool.Handoffs.Count(h => h.To == "agent-b").ShouldBe(2);
tool.Handoffs.ShouldAllBe(h => h.Delivered);
}
// ═══════════════════════════════════════════════════════════
// spawn zählt mit
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Auch_ueber_spawn_laesst_sich_die_Kette_nicht_umgehen()
{
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-a"
}, spawn: true);
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
await MitFrist(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" });
rueckweg.Delivered.ShouldBeFalse();
rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus");
}
// ═══════════════════════════════════════════════════════════
// #461 — was die Kette grundsätzlich nicht sehen kann
// ═══════════════════════════════════════════════════════════
/// <summary>
/// Zwei unabhängige Ketten über Kreuz: Lauf 1 startet bei A und beauftragt B, Lauf 2
/// startet zeitgleich bei B und beauftragt A. Keine der beiden Ketten enthält den
/// Agenten der anderen — beide sind für sich zyklusfrei, und trotzdem hält Lauf 1
/// gate(A) und wartet auf gate(B), während Lauf 2 gate(B) hält und auf gate(A) wartet.
///
/// <para>Das kann die Delegationskette prinzipiell nicht erkennen; nur eine Frist
/// löst es auf. Ein delegierter Lauf wartet deshalb höchstens
/// <c>DelegationGateTimeout</c> auf ein fremdes Gate und sagt danach ab.</para>
///
/// <para>Die Pause vor der Weitergabe ist der Kern des Aufbaus: Der Fake-Client
/// antwortet synchron, ohne sie liefe der erste Chat vollständig durch, bevor der
/// zweite beginnt — die Ketten könnten sich gar nicht überschneiden, und der Test
/// wäre grün, ohne etwas zu zeigen.</para>
/// </summary>
[Fact]
public async Task Zwei_unabhaengige_Ketten_ueber_Kreuz_verklemmen_sich_nicht()
{
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-a"
}, delayBeforeHandoff: TimeSpan.FromMilliseconds(300));
var fixture = new EngineFixture().WithTool(tool);
fixture.Engine.DelegationGateTimeout = TimeSpan.FromSeconds(1);
var a = fixture.AddAgent("agent-a", "TestTool");
var b = fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
var laufA = fixture.Engine.ChatAsync(a, "A fragt B", "test-instance", default);
var laufB = fixture.Engine.ChatAsync(b, "B fragt A", "test-instance", default);
var beide = Task.WhenAll(laufA, laufB);
var fertig = await Task.WhenAny(beide, Task.Delay(Frist));
fertig.ShouldBe(beide, "Beide Laeufe warten auf das Gate des jeweils anderen (#461).");
// Beide äußeren Läufe kommen zu Ende. Mindestens eine Weitergabe ist an der
// Frist gescheitert — welche, entscheidet die Reihenfolge und ist nicht
// festgelegt; dass beide Läufe zurückkommen, schon.
(await laufA).Status.ShouldBe(AgentRunStatus.Completed);
(await laufB).Status.ShouldBe(AgentRunStatus.Completed);
tool.Handoffs.ShouldContain(h => !h.Delivered && h.Error!.Contains("belegt"));
}
[Fact]
public async Task Ein_Chat_vom_Menschen_wartet_weiter_so_lange_es_dauert()
{
// Die Gegenprobe zur Frist: Sie gilt nur für delegierte Läufe. Ein zweiter
// Chat desselben Agenten reiht sich ein und wird bedient, auch wenn der erste
// länger braucht als die Delegationsfrist.
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(600)));
fixture.Engine.DelegationGateTimeout = TimeSpan.FromMilliseconds(50);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsContextually();
var erster = fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default);
var zweiter = fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default);
(await MitFrist(erster)).Status.ShouldBe(AgentRunStatus.Completed);
(await MitFrist(zweiter)).Status.ShouldBe(AgentRunStatus.Completed);
}
// ═══════════════════════════════════════════════════════════
// Die Kette selbst
// ═══════════════════════════════════════════════════════════
[Fact]
public void Die_Kette_zaehlt_Weitergaben_nicht_Agenten()
{
var kette = DelegationChain.Root("a");
kette.Depth.ShouldBe(0);
kette.Append("b").Depth.ShouldBe(1);
kette.Append("b").Append("c").Depth.ShouldBe(2);
}
[Fact]
public void Append_veraendert_die_Kette_des_Aufrufers_nicht()
{
// Unveränderlich: Zwei Weitergaben aus demselben Lauf dürfen sich nicht
// gegenseitig sehen — sonst zählte die zweite die erste als Vorgänger.
var kette = DelegationChain.Root("a");
var nachB = kette.Append("b");
var nachC = kette.Append("c");
kette.Agents.ShouldBe(["a"]);
nachB.Agents.ShouldBe(["a", "b"]);
nachC.Agents.ShouldBe(["a", "c"]);
nachB.Contains("c").ShouldBeFalse();
}
[Fact]
public void Die_Kette_liest_sich_als_Weg()
{
DelegationChain.Root("a").Append("b").Append("c").ToString()
.ShouldBe("a -> b -> c");
}
}
@@ -0,0 +1,170 @@
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// #203 — die Compaction lief nach dem API-Aufruf.
///
/// <para>Sie stützte sich auf die <c>promptTokens</c> der gerade beantworteten Anfrage:
/// Als der Wert vorlag, war der überfüllte Prompt längst abgerechnet, und gekürzt wurde
/// erst für den nächsten Schritt. Genau ein Schritt zu spät — und zwar der teuerste.</para>
///
/// <para>Die Zusage lautet jetzt: <b>Keine Anfrage geht über die Kontextgrenze hinaus.</b>
/// Geprüft wird vor dem Senden, mit der Schätzung; die gemessenen Werte ziehen die
/// Schätzung hinterher nach.</para>
/// </summary>
public sealed class ProactiveCompactionTests
{
private const string AgentModel = "test/model";
private const string SummaryModel = "test/summary";
[Fact]
public async Task Keine_Anfrage_geht_ueber_die_Kontextgrenze_hinaus()
{
var fixture = new EngineFixture();
var agent = fixture.AddAgent("agent-a");
// Schwelle: 1000 geschätzte Tokens.
agent.LoopGuard.MaxContextTokens = 2_000;
agent.LoopGuard.CompactionThreshold = 0.5;
agent.LoopGuard.SummaryModel = SummaryModel;
fixture.Client.AlwaysRespondsWithText("Verstanden.");
// Jede Nachricht bringt rund 150 geschaetzte Tokens mit; nach wenigen Runden
// waere der Kontext ohne Eingriff ueber der Grenze. Klein genug, dass auch die
// sechs geschuetzten Tail-Nachrichten zusammen noch unter die Grenze passen -
// sonst koennte selbst eine Kompaktierung sie nicht mehr unterschreiten.
for (var i = 0; i < 20; i++)
{
await fixture.Engine.ChatAsync(
agent, $"Frage {i}: " + new string('y', 600), "test-instance", default);
}
var anfragenAnDenAgenten = fixture.Client.ReceivedRequests
.Where(r => r.Model == AgentModel)
.ToList();
anfragenAnDenAgenten.Count.ShouldBe(20);
// Der eigentliche Punkt: nicht "irgendwann wurde gekürzt", sondern "es ging nie
// etwas Überfülltes hinaus". Vorher hing die Prüfung an den promptTokens der
// Antwort — beim Fake immer 100 —, kam also nie über die Schwelle und kürzte nie.
foreach (var anfrage in anfragenAnDenAgenten)
{
ContextCompactor.EstimateTokens(anfrage.Messages)
.ShouldBeLessThan(agent.LoopGuard.MaxContextTokens);
}
}
[Fact]
public async Task Gekuerzt_wird_mit_dem_guenstigen_Modell_bevor_gesendet_wird()
{
var fixture = new EngineFixture();
var agent = fixture.AddAgent("agent-a");
agent.LoopGuard.MaxContextTokens = 2_000;
agent.LoopGuard.CompactionThreshold = 0.5;
agent.LoopGuard.SummaryModel = SummaryModel;
fixture.Client.AlwaysRespondsWithText("Verstanden.");
for (var i = 0; i < 20; i++)
{
await fixture.Engine.ChatAsync(
agent, $"Frage {i}: " + new string('y', 600), "test-instance", default);
}
// Es wurde überhaupt kompaktiert — sonst sagt der Test darüber nichts aus.
fixture.Client.ReceivedRequests.ShouldContain(r => r.Model == SummaryModel);
}
// ═══════════════════════════════════════════════════════════
// Kalibrierung
// ═══════════════════════════════════════════════════════════
private static ContextCompactor NeuerKompaktierer()
=> new(new FakeChatClient(), TestLogging.Factory);
[Fact]
public void Ohne_Messwerte_gilt_die_rohe_Schaetzung()
{
var messages = Conversation.Start().Repeat(4).Build();
NeuerKompaktierer().EstimateFor("m", messages)
.ShouldBe(ContextCompactor.EstimateTokens(messages));
}
[Fact]
public void Eine_unterschaetzte_Anfrage_hebt_die_Schaetzung_an()
{
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
// Die API berechnet durchweg doppelt so viel wie geschätzt.
for (var i = 0; i < 20; i++)
compactor.Calibrate("m", 1_000, 2_000);
compactor.EstimateFor("m", messages).ShouldBeGreaterThan(roh);
}
[Fact]
public void Ein_einzelner_Ausreisser_kippt_die_Schwelle_nicht()
{
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
compactor.Calibrate("m", 1_000, 4_000); // einmal weit daneben
// Ein Messwert verschiebt den Faktor nur um sein Gewicht, nicht auf seinen Wert.
compactor.EstimateFor("m", messages).ShouldBeLessThan(roh * 2);
}
[Fact]
public void Ein_absurder_Messwert_wird_gedeckelt()
{
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
for (var i = 0; i < 50; i++)
compactor.Calibrate("m", 1, 1_000_000);
// Höchstens das Vierfache — was darüber läge, ist kein Kalibrierungsfall mehr.
compactor.EstimateFor("m", messages).ShouldBeLessThanOrEqualTo(roh * 4);
}
[Fact]
public void Jedes_Modell_wird_fuer_sich_kalibriert()
{
// Verschiedene Tokenizer zerlegen denselben Text verschieden — ein gemeinsamer
// Faktor wäre der Mittelwert von etwas, das nichts miteinander zu tun hat.
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
for (var i = 0; i < 20; i++)
compactor.Calibrate("gross", 1_000, 3_000);
compactor.EstimateFor("gross", messages).ShouldBeGreaterThan(roh);
compactor.EstimateFor("unberuehrt", messages).ShouldBe(roh);
}
[Fact]
public void Unbrauchbare_Messwerte_werden_verworfen()
{
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
compactor.Calibrate("m", 0, 5_000); // ohne Schätzung kein Verhältnis
compactor.Calibrate("m", 1_000, 0); // ohne Messwert auch nicht
compactor.EstimateFor("m", messages).ShouldBe(roh);
}
}
@@ -100,14 +100,14 @@ public sealed class TokenAccountingTests
var guard = new LoopGuardConfig
{
MaxContextTokens = 1_000,
MaxContextTokens = 100,
CompactionThreshold = 0.5,
SummaryModel = "google/gemini-2.5-flash"
};
var messages = Conversation.Start().Repeat(10).Build();
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "anthropic/claude-opus-4", default);
await compactor.CompactIfNeededAsync(messages, guard, "anthropic/claude-opus-4", default);
client.ReceivedRequests.ShouldNotBeEmpty();
client.ReceivedRequests[0].Model.ShouldBe("google/gemini-2.5-flash",
@@ -122,14 +122,14 @@ public sealed class TokenAccountingTests
var guard = new LoopGuardConfig
{
MaxContextTokens = 1_000,
MaxContextTokens = 100,
CompactionThreshold = 0.5,
SummaryModel = ""
};
var messages = Conversation.Start().Repeat(10).Build();
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "anthropic/claude-opus-4", default);
await compactor.CompactIfNeededAsync(messages, guard, "anthropic/claude-opus-4", default);
client.ReceivedRequests[0].Model.ShouldBe("anthropic/claude-opus-4");
}
@@ -146,14 +146,14 @@ public sealed class TokenAccountingTests
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung.");
var compactor = new ContextCompactor(client, TestLogging.Factory);
var guard = new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5 };
var guard = new LoopGuardConfig { MaxContextTokens = 100, CompactionThreshold = 0.5 };
var messages = Conversation.Start()
.Repeat(8)
.User("EINZIGARTIGE-LETZTE-NACHRICHT")
.Build();
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "test/model", default);
await compactor.CompactIfNeededAsync(messages, guard, "test/model", default);
var summaryPrompt = client.ReceivedRequests[0].Messages.Last().Content!;
summaryPrompt.ShouldNotContain("EINZIGARTIGE-LETZTE-NACHRICHT");
@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using System.Text.Json;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Ein Tool, das einen anderen Agenten beauftragt — der Nachbau dessen, was
/// <c>AgentComm.send_message</c> und <c>AgentSpawn.spawn_agent</c> tun.
///
/// <para>Der Nachbau ist nötig, weil beide Werkzeuge in eigenen Projekten liegen, die
/// <c>ClawdDotNet.Core.Tests</c> nicht referenziert. Entscheidend ist ohnehin nur die
/// eine Zeile, in der sie sich hier gleichen: Die Delegationskette des eigenen Laufs
/// wird weitergereicht. Ein Tool, das sie unterschlägt, macht aus einem Zyklus eine
/// neue Beauftragung — genau der Fall, den B8 beschreibt.</para>
///
/// <para>Wen ein Agent beauftragt, steht in <paramref name="routes"/>: agentId → Ziel.
/// Wer dort nicht vorkommt, delegiert nicht und antwortet schlicht.</para>
/// </summary>
internal sealed class DelegatingTool : IAgentTool
{
private readonly IReadOnlyDictionary<string, string> _routes;
private readonly bool _spawn;
private readonly TimeSpan _delayBeforeHandoff;
public string Name => "TestTool";
public string Description => "Beauftragt einen anderen Agenten.";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{ "type": "object", "properties": { "message": { "type": "string" } } }
""").RootElement.Clone();
/// <summary>Was zurückkam — Aufrufer, Ziel und das Ergebnis der Weitergabe.</summary>
public ConcurrentBag<(string From, string To, bool Delivered, string? Error)> Handoffs { get; } = new();
/// <param name="delayBeforeHandoff">
/// Wartezeit, bevor weitergegeben wird. Der Fake-Client antwortet synchron; ohne
/// diese Pause läuft ein Chat vollständig durch, bevor der nächste überhaupt
/// beginnt, und zwei Ketten können sich nicht überschneiden.
/// </param>
public DelegatingTool(
IReadOnlyDictionary<string, string> routes,
bool spawn = false,
TimeSpan delayBeforeHandoff = default)
{
_routes = routes;
_spawn = spawn;
_delayBeforeHandoff = delayBeforeHandoff;
}
public async Task<ToolResult> ExecuteAsync(
JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (!_routes.TryGetValue(context.AgentId, out var target))
return ToolResult.Ok("nichts zu delegieren");
if (_delayBeforeHandoff > TimeSpan.Zero)
await Task.Delay(_delayBeforeHandoff, ct);
if (_spawn)
{
var spawned = await context.MessageRouter!.SpawnAgentAsync(
context.AgentId, target, "Auftrag", context.InstanceId, context.Delegation, ct);
Handoffs.Add((context.AgentId, target, spawned.Started, spawned.Error));
return spawned.Started
? ToolResult.Ok(spawned.FinalMessage ?? "")
: ToolResult.Ok(JsonSerializer.Serialize(new { spawned = false, error = spawned.Error }));
}
var result = await context.MessageRouter!.SendMessageAsync(
context.AgentId, target, "Frage", context.InstanceId, context.Delegation, ct);
Handoffs.Add((context.AgentId, target, result.Delivered, result.Error));
// Wie das echte Tool: Eine Ablehnung ist kein Wurf, sondern ein Ergebnis, das
// der Agent lesen und auf das er anders reagieren kann.
return result.Delivered
? ToolResult.Ok(result.Response ?? "")
: ToolResult.Ok(JsonSerializer.Serialize(new { delivered = false, error = result.Error }));
}
}
@@ -50,7 +50,7 @@ public sealed class SecretProtectorTests : IDisposable
var protectedValue = SecretProtector.Protect(secret);
protectedValue.ShouldNotContain(secret);
protectedValue.ShouldNotBeNull().ShouldNotContain(secret);
protectedValue.ShouldNotBe(secret);
}
@@ -190,7 +190,14 @@ public sealed class SecretProtectorTests : IDisposable
SecretProtector.Unprotect(second).ShouldBe("noch-geheimer");
}
/// <summary>
/// <c>[LinuxFact]</c> hält den Test unter Windows an, der Plattform-Analysator sieht
/// das aber nicht — er kennt nur Attribute wie <c>SupportedOSPlatform</c>. Ohne die
/// Angabe blieb CA1416 als einzige Warnung im Build stehen, und eine einzelne
/// Warnung, die immer da ist, liest bald niemand mehr.
/// </summary>
[LinuxFact]
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
public void Die_Schluesseldatei_ist_nur_fuer_den_Besitzer_lesbar()
{
SecretProtector.Protect("geheim");
@@ -207,7 +214,7 @@ public sealed class SecretProtectorTests : IDisposable
var ex = Should.Throw<SecretProtectionException>(
() => SecretProtector.Unprotect("enc:v1:" + Convert.ToBase64String([1, 2, 3, 4])));
ex.Message.ShouldContain("neu eingetragen");
ex.Message.ShouldNotBeNull().ShouldContain("neu eingetragen");
}
}
@@ -270,8 +277,8 @@ public sealed class ConfigSecretsTests : IDisposable
ConfigSecrets.Protect(agent);
agent.Tools["Database"]["connectionString"]!.ToString().ShouldNotContain("geheim");
agent.Tools["Mail"]["password"]!.ToString().ShouldNotContain("mail-passwort");
agent.Tools["Database"]["connectionString"]!.ToString().ShouldNotBeNull().ShouldNotContain("geheim");
agent.Tools["Mail"]["password"]!.ToString().ShouldNotBeNull().ShouldNotContain("mail-passwort");
}
[Fact]
@@ -311,7 +318,7 @@ public sealed class ConfigSecretsTests : IDisposable
ConfigSecrets.Protect(agent);
agent.Tools["Mail"]["password"]!.ToString().ShouldNotContain("geheim123");
agent.Tools["Mail"]["password"]!.ToString().ShouldNotBeNull().ShouldNotContain("geheim123");
ConfigSecrets.Unprotect(agent);
agent.Tools["Mail"]["password"].ShouldBe("geheim123");
}
@@ -243,4 +243,94 @@ public sealed class TaskScheduleTests
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
.ShouldBe("2026-07-31T09:00:00.0000000Z");
}
// ═══════════════════════════════════════════════════════════
// Zeitumstellung — R4 und R5 der Teststrategie
//
// Europe/Berlin 2026: Am 29. März springt die Uhr um 02:00 auf 03:00, am
// 25. Oktober um 03:00 zurück auf 02:00. Ein täglicher Termin um 02:00 trifft
// also einmal im Jahr eine Uhrzeit, die es nicht gibt, und einmal eine, die es
// doppelt gibt.
// ═══════════════════════════════════════════════════════════
private static TaskItem TaeglichUmZwei(DateTime angelegt) => new()
{
Id = "t-dst",
CreatedAt = angelegt,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 2 * * *", TimeZone = "Europe/Berlin" }
};
/// <summary>R5 — der Fall, um den es in #235 geht.</summary>
[Fact]
public void Zeitumstellung_Maerz_wird_nicht_uebersprungen()
{
var task = TaeglichUmZwei(Utc(2026, 3, 28, 0, 0));
// "02:00" existiert am 29. März nicht. Der Termin fällt trotzdem an, und zwar
// auf dem ersten Zeitpunkt danach: 03:00 MESZ = 01:00 UTC.
// Vorher kam hier null — die Aufgabe fiel an diesem Tag ersatzlos aus.
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 2, 0))
.ShouldBe("2026-03-29T01:00:00.0000000Z");
}
[Fact]
public void Der_verschobene_Termin_feuert_trotzdem_nur_einmal()
{
var task = TaeglichUmZwei(Utc(2026, 3, 28, 0, 0));
var ersteFaelligkeit = TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 2, 0));
ersteFaelligkeit.ShouldNotBeNull();
TaskSchedule.DueOccurrence(task with { LastOccurrence = ersteFaelligkeit }, Utc(2026, 3, 29, 2, 30))
.ShouldBeNull();
}
/// <summary>R4 — die andere Richtung.</summary>
[Fact]
public void Zeitumstellung_Oktober_feuert_genau_einmal()
{
var task = TaeglichUmZwei(Utc(2026, 10, 24, 0, 0));
// 02:00 kommt am 25. Oktober zweimal vor: in Sommerzeit (00:00 UTC) und in
// Normalzeit (01:00 UTC). Beide Durchläufe ergeben denselben Occurrence-Key —
// der Marker fängt den zweiten ab.
var ersterDurchlauf = TaskSchedule.DueOccurrence(task, Utc(2026, 10, 25, 0, 30));
ersterDurchlauf.ShouldNotBeNull();
var zweiterDurchlauf = TaskSchedule.DueOccurrence(
task with { LastOccurrence = ersterDurchlauf }, Utc(2026, 10, 25, 1, 30));
zweiterDurchlauf.ShouldBeNull();
}
[Fact]
public void An_gewoehnlichen_Tagen_bleibt_es_bei_zwei_Uhr_Ortszeit()
{
// Die Gegenprobe: Die Verschiebung darf nur in der Lücke greifen.
var task = TaeglichUmZwei(Utc(2026, 3, 21, 0, 0));
// 02:00 MEZ = 01:00 UTC.
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 22, 2, 0))
.ShouldBe("2026-03-22T01:00:00.0000000Z");
}
[Fact]
public void Auch_ein_einmaliger_Termin_in_der_Luecke_faellt_nicht_aus()
{
// Derselbe Umgang auf dem at-Pfad — dort galt dieselbe Regel und dieselbe Lücke.
var task = new TaskItem
{
Id = "t-1",
When = new TaskWhen
{
Kind = TaskWhenKind.At,
Value = "2026-03-29T02:30:00",
TimeZone = "Europe/Berlin"
}
};
// 02:30 gibt es nicht; es wird 03:00 MESZ = 01:00 UTC.
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 0, 59)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 1, 0))
.ShouldBe("2026-03-29T01:00:00.0000000Z");
}
}
@@ -53,7 +53,7 @@ public sealed class SqlGuardTests
var result = Inspect(sql);
result.IsValid.ShouldBeFalse();
result.Error.ShouldContain("Mehrere Statements");
result.Error.ShouldNotBeNull().ShouldContain("Mehrere Statements");
}
[Theory]
@@ -66,7 +66,7 @@ public sealed class SqlGuardTests
var result = Inspect(sql);
result.IsValid.ShouldBeFalse();
result.Error.ShouldContain("users");
result.Error.ShouldNotBeNull().ShouldContain("users");
}
[Fact]
@@ -144,8 +144,8 @@ public sealed class SqlGuardTests
{
var result = Inspect("SELECT * FROM geheim");
result.Error.ShouldContain("geheim");
result.Error.ShouldContain("prices");
result.Error.ShouldNotBeNull().ShouldContain("geheim");
result.Error.ShouldNotBeNull().ShouldContain("prices");
}
[Fact]
@@ -54,8 +54,14 @@ public sealed class WorkspacePathTests : IDisposable
/// Die Windows-Formen müssen auch unter Linux abgewiesen werden — dort greift
/// aber eine andere Regel: <c>Path.IsPathRooted(@"C:\temp\x")</c> ist unter Linux
/// <c>false</c>, weil das schlicht ein Dateiname mit Doppelpunkt ist. Gefangen
/// werden die Fälle dann von der Doppelpunkt- bzw. der <c>\\</c>-Prüfung.
/// Das Ergebnis ist auf beiden Plattformen dasselbe, der Weg dorthin nicht.
/// werden diese Fälle von der Zeichenregel: Doppelpunkt und Backslash sind im
/// Pfad unzulässig, getrennt wird mit <c>/</c>. Das Ergebnis ist auf beiden
/// Plattformen dasselbe, der Weg dorthin nicht.
///
/// Dieselbe Regel fängt unter Linux auch <c>@"..\..\evil.txt"</c> aus dem Test
/// darüber. Ein Ausbruch war das dort nie — der Backslash trennt unter Linux
/// nichts, die Datei wäre im Workspace gelandet —, aber der Test erwartete eine
/// Abweisung und bekam keine.
/// </summary>
[Theory]
[InlineData(@"C:\Windows\System32\config\SAM")]