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">