diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx
index 4fc60b6..1036b8b 100644
--- a/ClawdDotNet.slnx
+++ b/ClawdDotNet.slnx
@@ -21,6 +21,7 @@
+
diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 97e8d2e..4286c91 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -127,6 +127,45 @@ public sealed class AppHost : IAsyncDisposable
///
public readonly record struct StartupResult(AppHost? Host, string? Error);
+ ///
+ /// Wohin protokolliert wird. Zwei Regeln, die vorher beide nicht galten:
+ ///
+ /// Leer heißt „nimm die Anwendungseinstellung". Die steht absolut unter
+ /// AppPaths.DataDirectory und ist der Wert, den die Einstellungsseite zeigt.
+ /// Vorher war InstanceConfig.LogDirectory mit "./Logs" 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.
+ ///
+ /// Ein relativer Wert zählt gegen das Instanzverzeichnis, nicht gegen
+ /// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem
+ /// Verzeichnis jemand gestartet hat: Ein systemd-Dienst ohne WorkingDirectory=
+ /// startet in /, und "./Logs" zeigt dann auf /Logs — für ein
+ /// Dienstkonto nicht anlegbar. Beim Start aus dem Installationsverzeichnis schrieb
+ /// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt.
+ ///
+ /// Die geladene Instanz — liefert Vorrang und Bezugspunkt.
+ /// Der Wert aus den Anwendungseinstellungen.
+ /// Das Verzeichnis, aus dem die Instanz geladen wurde.
+ 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 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(
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(
diff --git a/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs b/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs
index 4dd4c32..989b267 100644
--- a/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs
+++ b/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs
@@ -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(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");
diff --git a/src/ClawdDotNet.App/Services/LogTail.cs b/src/ClawdDotNet.App/Services/LogTail.cs
index 2248778..4bf7cb7 100644
--- a/src/ClawdDotNet.App/Services/LogTail.cs
+++ b/src/ClawdDotNet.App/Services/LogTail.cs
@@ -26,9 +26,18 @@ public sealed partial class LogTail(string logDirectory)
{
private readonly ConcurrentDictionary _positions = new();
+ ///
+ /// Der zuletzt gesehene Zeitstempel je Datei. Trägt Fortsetzungszeilen über
+ /// Poll-Grenzen hinweg — siehe .
+ ///
+ private readonly ConcurrentDictionary _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();
+
/// Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld.
public IReadOnlyList AvailableModules()
{
@@ -44,7 +53,8 @@ public sealed partial class LogTail(string logDirectory)
}
///
- /// Alles, was seit dem letzten Aufruf dazugekommen ist.
+ /// Alles, was seit dem letzten Aufruf dazugekommen ist — in der Reihenfolge, in
+ /// der es entstanden ist.
///
/// leer heißt: alle Module.
///
@@ -53,9 +63,15 @@ public sealed partial class LogTail(string logDirectory)
var directory = TodayDirectory();
if (directory is null) return [];
- var result = new List();
+ 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 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() : "";
}
+ ///
+ /// 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.
+ ///
+ 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,
diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
index 1d57e87..f00dd4b 100644
--- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs
+++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
@@ -16,8 +16,17 @@ public sealed class InstanceConfig
[JsonPropertyName("workingDirectory")]
public string WorkingDirectory { get; set; } = "./data/";
+ ///
+ /// Wohin diese Instanz protokolliert. Leer heißt: das Log-Verzeichnis aus den
+ /// Anwendungseinstellungen, also der absolute Pfad unter AppPaths.DataDirectory.
+ ///
+ /// Die frühere Vorbelegung "./Logs" 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 AppHost.StartAsync).
+ ///
[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();
+ ///
+ /// 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.
+ ///
+ [JsonPropertyName("maxDelegationDepth")]
+ public int MaxDelegationDepth { get; set; } = 3;
+
+ ///
+ /// 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.
+ ///
+ [JsonPropertyName("delegationGateTimeoutSeconds")]
+ public int DelegationGateTimeoutSeconds { get; set; } = 120;
+
[JsonPropertyName("agents")]
public List Agents { get; set; } = new();
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index 14d5773..72db757 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -32,6 +32,36 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// Tagesgrenzen der Instanz. Wird vom Host gesetzt.
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
+
+ ///
+ /// 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.
+ ///
+ public int MaxDelegationDepth { get; set; } = 3;
+
+ ///
+ /// Wie lange ein delegierter Lauf höchstens auf das Gate seines Zielagenten
+ /// wartet (#461). Danach kommt eine Absage statt weiterer Wartezeit.
+ ///
+ /// 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.
+ ///
+ /// 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.
+ ///
+ public TimeSpan DelegationGateTimeout { get; set; } = TimeSpan.FromSeconds(120);
+
private readonly ContextCompactor _compactor;
private readonly Dictionary> _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 ───
+ ///
+ /// Darf dieser Aufruf weitergegeben werden (B8)? Gibt den Ablehnungsgrund zurück
+ /// oder null, wenn nichts dagegen spricht.
+ ///
+ /// 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.
+ ///
+ 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 ListAgents(string callerAgentId)
{
var configs = _agentConfigProvider?.Invoke() ?? [];
@@ -709,8 +797,19 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
.ToList();
}
+ ///
+ /// Die Instanz des Aufrufers, sonst die der Engine (B13).
+ ///
+ /// _instanceId wird nur in SetAgentConfigProvider 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.
+ ///
+ private string ResolveInstanceId(string? fromCaller)
+ => string.IsNullOrWhiteSpace(fromCaller) ? _instanceId : fromCaller;
+
public async Task 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 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));
}
///
diff --git a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs
index fdae986..4a536dc 100644
--- a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs
@@ -31,5 +31,12 @@ public enum AgentRunStatus
LoopLimitExceeded,
/// Der Lauf fand nicht statt, weil das Tagesbudget erschöpft ist.
- BudgetExceeded
+ BudgetExceeded,
+
+ ///
+ /// 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.
+ ///
+ TargetBusy
}
diff --git a/src/ClawdDotNet.Core/Engine/ContextCompactor.cs b/src/ClawdDotNet.Core/Engine/ContextCompactor.cs
index 469c189..4499990 100644
--- a/src/ClawdDotNet.Core/Engine/ContextCompactor.cs
+++ b/src/ClawdDotNet.Core/Engine/ContextCompactor.cs
@@ -14,12 +14,32 @@ public sealed class ContextCompactor
private const int MaxToolResultChars = 2000;
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
+ ///
+ /// 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.
+ ///
+ private const double CalibrationWeight = 0.25;
+
+ ///
+ /// 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.
+ ///
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _calibration = new();
+
public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory)
{
_client = client;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
}
+ ///
+ /// Die rohe Schätzung: vier Zeichen je Token. Grob, aber ohne Netzaufruf und ohne
+ /// Kenntnis des Tokenizers — und darauf kommt es an, weil sie vor dem Senden
+ /// gebraucht wird. zieht die gemessene Abweichung nach.
+ ///
public static int EstimateTokens(List messages)
{
var totalChars = 0;
@@ -37,16 +57,51 @@ public sealed class ContextCompactor
return totalChars / 4;
}
+ ///
+ /// Die Schätzung für ein bestimmtes Modell — die rohe Rechnung, korrigiert um das,
+ /// was bei diesem Modell bisher tatsächlich gemessen wurde.
+ ///
+ public int EstimateFor(string model, List messages)
+ => (int)(EstimateTokens(messages) * _calibration.GetValueOrDefault(model, 1.0));
+
+ ///
+ /// Meldet, wie groß der Prompt wirklich war, den eine Schätzung vorhergesagt hat.
+ ///
+ /// 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.
+ ///
+ /// 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.
+ ///
+ 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 CompactIfNeededAsync(
List 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);
diff --git a/src/ClawdDotNet.Core/Storage/PathBoundary.cs b/src/ClawdDotNet.Core/Storage/PathBoundary.cs
index 5b01c15..efa16cb 100644
--- a/src/ClawdDotNet.Core/Storage/PathBoundary.cs
+++ b/src/ClawdDotNet.Core/Storage/PathBoundary.cs
@@ -55,13 +55,18 @@ public static class PathBoundary
/// den abschließenden Trenner gälte …/Workspace-Backup als Teil von
/// …/Workspace.
///
- /// Symbolische Verknüpfungen werden aufgelöst () — ein
- /// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb.
+ /// Symbolische Verknüpfungen werden aufgelöst () — 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.
///
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;
}
+ ///
+ /// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
+ ///
+ ///
+ /// Der Pfad ist nicht bestimmbar. meldet das ohne Wurf.
+ ///
+ public static string Canonicalize(string path)
+ => TryCanonicalize(path, out var canonical)
+ ? canonical
+ : throw new IOException($"Pfad nicht bestimmbar: {path}");
+
///
/// 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 false 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.
///
- 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;
}
}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs b/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs
index 8a3958f..990a803 100644
--- a/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs
+++ b/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs
@@ -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;
+ }
+
+ ///
+ /// Rechnet eine Wanduhrzeit in ihren UTC-Zeitpunkt um — auch dann, wenn es diese
+ /// Uhrzeit an diesem Tag gar nicht gibt.
+ ///
+ /// Der Frühjahrssprung. 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.
+ ///
+ /// 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.
+ ///
+ /// Der Herbstsprung braucht nichts Eigenes: Eine doppelt vorkommende
+ /// Uhrzeit rechnet ConvertTimeToUtc 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.
+ ///
+ 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;
}
diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
index 16445f7..0dfb65c 100644
--- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
+++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
@@ -16,5 +16,12 @@ public sealed record AgentToolContext(
string? SharedWorkspacePath = null,
IAgentMessageRouter? MessageRouter = null,
IMemoryRepository? Memory = null,
- ITaskRepository? Tasks = null
+ ITaskRepository? Tasks = null,
+
+ ///
+ /// 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 .
+ ///
+ DelegationChain? Delegation = null
);
diff --git a/src/ClawdDotNet.Core/Tools/DelegationChain.cs b/src/ClawdDotNet.Core/Tools/DelegationChain.cs
new file mode 100644
index 0000000..488ac32
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tools/DelegationChain.cs
@@ -0,0 +1,56 @@
+namespace ClawdDotNet.Core.Tools;
+
+///
+/// Wer an diesem Aufruf schon beteiligt ist — die Kette vom ersten Agenten bis zum
+/// gerade laufenden.
+///
+/// Warum es die Kette gibt (B8).send_message 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.
+///
+/// Die Zusagen, auf denen alles Weitere aufsetzt:
+///
+/// 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.
+/// Eine Kette ist höchstens MaxDelegationDepth 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.
+/// Die Kette gehört zum Aufruf, nicht zum Agenten. Zwei unabhängige
+/// Läufe desselben Agenten behindern einander nicht.
+///
+///
+/// Unveränderlich: 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.
+///
+public sealed class DelegationChain
+{
+ private readonly string[] _agents;
+
+ private DelegationChain(string[] agents) => _agents = agents;
+
+ /// Der Anfang: ein Lauf, den niemand delegiert hat.
+ public static DelegationChain Root(string agentId) => new([agentId]);
+
+ /// Die Agenten in der Reihenfolge, in der sie beauftragt wurden.
+ public IReadOnlyList Agents => _agents;
+
+ ///
+ /// Die Zahl der Weitergaben — nicht die der Agenten. Ein Lauf ohne Delegation hat
+ /// die Tiefe 0, A→B die Tiefe 1.
+ ///
+ public int Depth => _agents.Length - 1;
+
+ /// Ist dieser Agent in der Kette schon vertreten?
+ public bool Contains(string agentId)
+ => Array.IndexOf(_agents, agentId) >= 0;
+
+ /// Die Kette mit einem weiteren Agenten am Ende.
+ public DelegationChain Append(string agentId)
+ => new([.. _agents, agentId]);
+
+ /// Für Meldungen und Protokoll: A -> B -> C.
+ public override string ToString() => string.Join(" -> ", _agents);
+}
diff --git a/src/ClawdDotNet.Core/Tools/IAgentMessageRouter.cs b/src/ClawdDotNet.Core/Tools/IAgentMessageRouter.cs
index f42b5ae..ebc1280 100644
--- a/src/ClawdDotNet.Core/Tools/IAgentMessageRouter.cs
+++ b/src/ClawdDotNet.Core/Tools/IAgentMessageRouter.cs
@@ -10,15 +10,32 @@ public interface IAgentMessageRouter
{
IReadOnlyList ListAgents(string callerAgentId);
+ ///
+ /// Die Instanz des aufrufenden Laufs — aus
+ /// 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).
+ ///
+ ///
+ /// Die Delegationskette des aufrufenden Laufs — aus
+ /// durchgereicht. null 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).
+ ///
Task SendMessageAsync(
string fromAgentId,
string toAgentId,
string message,
+ string? instanceId,
+ DelegationChain? chain,
CancellationToken ct);
+ ///
Task SpawnAgentAsync(
string fromAgentId,
string targetAgentId,
string taskMessage,
+ string? instanceId,
+ DelegationChain? chain,
CancellationToken ct);
}
diff --git a/src/ClawdDotNet.Desktop/Styles/Theme.axaml b/src/ClawdDotNet.Desktop/Styles/Theme.axaml
index 0fd9d6f..a809100 100644
--- a/src/ClawdDotNet.Desktop/Styles/Theme.axaml
+++ b/src/ClawdDotNet.Desktop/Styles/Theme.axaml
@@ -31,11 +31,25 @@
#1d1f20#5980a6
-
-
-
-
-
+
+
+
+
+
@@ -96,10 +110,13 @@
#eef0f1#7ea3c6
+
-
+
diff --git a/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml b/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml
index 3cb6c24..a671a3d 100644
--- a/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml
+++ b/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml
@@ -100,8 +100,12 @@
+
+ Margin="16,0,0,0">
+
-
+
-
+
@@ -97,11 +110,11 @@
-
+
-
+
diff --git a/src/ClawdDotNet.Tools.AgentComm/AgentCommTool.cs b/src/ClawdDotNet.Tools.AgentComm/AgentCommTool.cs
index 0635ec8..9a38864 100644
--- a/src/ClawdDotNet.Tools.AgentComm/AgentCommTool.cs
+++ b/src/ClawdDotNet.Tools.AgentComm/AgentCommTool.cs
@@ -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)
{
diff --git a/src/ClawdDotNet.Tools.AgentSpawn/AgentSpawnTool.cs b/src/ClawdDotNet.Tools.AgentSpawn/AgentSpawnTool.cs
index a51d966..f0970b7 100644
--- a/src/ClawdDotNet.Tools.AgentSpawn/AgentSpawnTool.cs
+++ b/src/ClawdDotNet.Tools.AgentSpawn/AgentSpawnTool.cs
@@ -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)
{
diff --git a/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs b/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs
index 0c220a6..b3bf6fc 100644
--- a/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs
+++ b/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs
@@ -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 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);
+ }
+
+ ///
+ /// Das Ergebnis eines API-Aufrufs: entweder Daten oder eine Absage —
+ /// nie beides, nie keines von beiden.
+ ///
+ /// Vorher war das ein Tupel (JsonElement? json, ToolResult? error), und
+ /// jede der elf Aufrufstellen las danach json.Value. Dass der Wert nach
+ /// if (error != null) return error; 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.
+ ///
+ /// sagt dasselbe so, dass es nachvollziehbar ist: Wer
+ /// true bekommt, hat die Daten; wer false bekommt, hat die Absage,
+ /// die er zurückgeben soll.
+ ///
+ 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;
+ }
}
///
/// Sichere JSON-Antwort von einer API abrufen.
/// Prüft HTTP-Status und ob die Antwort tatsächlich JSON ist, bevor geparst wird.
///
- private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync(
+ private static async Task 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}"));
}
}
diff --git a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs
index bc94126..b9662ad 100644
--- a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs
+++ b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs
@@ -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)
diff --git a/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs b/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs
index 9f1400c..b106f1d 100644
--- a/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs
+++ b/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs
@@ -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);
diff --git a/src/ClawdDotNet.Tools.Mail/MailTool.cs b/src/ClawdDotNet.Tools.Mail/MailTool.cs
index 29dc491..ec8d820 100644
--- a/src/ClawdDotNet.Tools.Mail/MailTool.cs
+++ b/src/ClawdDotNet.Tools.Mail/MailTool.cs
@@ -80,8 +80,8 @@ public sealed class MailTool : IAgentTool, IToolJobProvider
private async Task 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 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