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
+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
{