Befunde aus dem Testbetrieb auf der Linux-VM behoben (#572)

- #578: Abgebrochene Laeufe (Schrittgrenze, Zeitueberschreitung, Abbruch,
  Ausnahme) tragen ihre bezahlten Token - vorher 0 in RunUsage, fuer das
  Budget unsichtbar.
- #580: WebFetch prueft die aufgeloeste Zieladresse beim Verbinden; ein
  erlaubter Name, der auf 127.0.0.1 aufloest, wird gesperrt.
- #575: Dateien entstehen unter Unix mit 0600 (AtomicFile, Protokolle,
  FileRW-Anhaengen, state.db), Protokollverzeichnisse mit 0700.
- #576: SIGTERM/SIGINT beenden auch mit Fenster geordnet.
- #593: OpenRouter-Statusdienst wird gestartet.
- #589: Auftragsnachricht nennt aktuelle Zeit, Zeitzone und Termin.
- #581: Unvollstaendiges Argument-JSON erreicht das Werkzeug nicht mehr;
  das Modell bekommt eine verstaendliche Rueckmeldung statt Parsertext.
- #591: Weboberflaeche liest die Felder, die API und Ereignisstrom liefern
  (Freigaben, Protokoll, Taskboard mit Termin, Werkzeug-Einstellungen,
  Tokennutzung); Ereignisstrom in camelCase.
- #592: Chat zeigt Nachrichten nicht mehr doppelt.
- #536 (teilweise): A2A-Antworten tragen Absender und Empfaenger, Parser
  wirft nicht mehr; Datums- und Volltextfilter in Agenten-Chats.
- deploy/publish.py: Packager-Pfad net10.0, Schalter --agent-file.
- Version 0.1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-14 15:40:56 +02:00
co-authored by Claude Opus 5
parent e2567a466f
commit 6581fd0187
29 changed files with 1128 additions and 190 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe. gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
--> -->
<Version>0.1.2</Version> <Version>0.1.4</Version>
<!-- <!--
Symbole in die Assembly einbetten statt als .pdb daneben zu legen. Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
+15 -3
View File
@@ -37,7 +37,7 @@ PROJECT = REPO / "src" / "ClawdDotNet.Desktop" / "ClawdDotNet.Desktop.csproj"
CONFIG = REPO / "deploy" / "packager.config.json" CONFIG = REPO / "deploy" / "packager.config.json"
PACKAGER = Path( PACKAGER = Path(
"J:/Softwareprojekte/Deploymentcenter/client-dotnet/Deploymentcenter.Packager" "J:/Softwareprojekte/Deploymentcenter/client-dotnet/Deploymentcenter.Packager"
"/bin/Release/net8.0/pack-and-deploy.exe" "/bin/Release/net10.0/win-x64/pack-and-deploy.exe"
) )
INSTALLER_BASE = "https://dc.mhdf.de/installer" INSTALLER_BASE = "https://dc.mhdf.de/installer"
@@ -69,13 +69,18 @@ def publish(rid: str, out_dir: Path) -> None:
]) ])
def fetch_agent(rid: str, out_dir: Path) -> None: def fetch_agent(rid: str, out_dir: Path, agent_file: Path | None = None) -> None:
"""Holt den update-agent und prueft die Pruefsumme. """Holt den update-agent und prueft die Pruefsumme.
Bewusst das ausgelieferte Binary statt eines selbst gebauten: Es ist Bewusst das ausgelieferte Binary statt eines selbst gebauten: Es ist
dasselbe, das die Erstinstallation verwendet, und wird zentral gepflegt. dasselbe, das die Erstinstallation verwendet, und wird zentral gepflegt.
Ohne Pruefsummenvergleich waere das ein Download, der spaeter fremden Code Ohne Pruefsummenvergleich waere das ein Download, der spaeter fremden Code
ausfuehrt - genau der Pfad, den eine Signatur schuetzen soll. ausfuehrt - genau der Pfad, den eine Signatur schuetzen soll.
agent_file ersetzt nur den Download, nicht die Pruefung: Die Ablage liefert
zeitweise um 100 KB/s, und urllib blieb dabei am 2026-09-14 ohne Meldung
stehen. Eine vorhandene Kopie muss dieselbe Pruefsumme tragen wie das
Manifest - sonst ist sie nicht das ausgelieferte Binary.
""" """
with urllib.request.urlopen(f"{INSTALLER_BASE}/installer.json", timeout=30) as r: with urllib.request.urlopen(f"{INSTALLER_BASE}/installer.json", timeout=30) as r:
manifest = json.load(r) manifest = json.load(r)
@@ -86,6 +91,10 @@ def fetch_agent(rid: str, out_dir: Path) -> None:
print(f" update-agent {manifest.get('version')} fuer {rid} ({entry['sizeBytes']} Bytes)") print(f" update-agent {manifest.get('version')} fuer {rid} ({entry['sizeBytes']} Bytes)")
if agent_file is not None:
print(f" aus Datei: {agent_file}")
payload = agent_file.read_bytes()
else:
with urllib.request.urlopen(f"{INSTALLER_BASE}/{entry['file']}", timeout=180) as r: with urllib.request.urlopen(f"{INSTALLER_BASE}/{entry['file']}", timeout=180) as r:
payload = r.read() payload = r.read()
@@ -109,6 +118,9 @@ def main() -> None:
parser.add_argument("--changelog", default="") parser.add_argument("--changelog", default="")
parser.add_argument("--no-agent", action="store_true", parser.add_argument("--no-agent", action="store_true",
help="update-agent nicht mitliefern") help="update-agent nicht mitliefern")
parser.add_argument("--agent-file", type=Path,
help="vorhandene Kopie des update-agent statt Download; "
"die Pruefsumme wird trotzdem gegen installer.json geprueft")
parser.add_argument("--dry-run", action="store_true", parser.add_argument("--dry-run", action="store_true",
help="nur bauen und pruefen, nicht hochladen") help="nur bauen und pruefen, nicht hochladen")
args = parser.parse_args() args = parser.parse_args()
@@ -125,7 +137,7 @@ def main() -> None:
if args.no_agent: if args.no_agent:
print(" uebersprungen (--no-agent)") print(" uebersprungen (--no-agent)")
else: else:
fetch_agent(args.rid, out_dir) fetch_agent(args.rid, out_dir, args.agent_file)
total = sum(f.stat().st_size for f in out_dir.rglob("*") if f.is_file()) total = sum(f.stat().st_size for f in out_dir.rglob("*") if f.is_file())
print(f"\n {len(list(out_dir.rglob('*')))} Eintraege, {total / 1_048_576:.1f} MB") print(f"\n {len(list(out_dir.rglob('*')))} Eintraege, {total / 1_048_576:.1f} MB")
+9
View File
@@ -580,6 +580,15 @@ public sealed class AppHost : IAsyncDisposable
host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; }); host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
// #593: Angelegt wurde der Dienst schon immer, gestartet nie — weder kopflos noch
// mit Fenster. Die Kopfzeile der Weboberflaeche stand deshalb dauerhaft auf
// "OpenRouter: —" und /api/status auf "Prüfe…".
if (host.Status is { } status)
{
status.Start();
host._shutdown.Add(status.DisposeAsync);
}
logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen"); logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
return host; return host;
+54 -9
View File
@@ -183,6 +183,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
logger.LogInformation("Agent run started: {AgentId}, model={Model}", logger.LogInformation("Agent run started: {AgentId}, model={Model}",
agentConfig.AgentId, agentConfig.Model); agentConfig.AgentId, agentConfig.Model);
// #578: Vor dem try, damit auch die Abbruchzweige wissen, was bis dahin bezahlt
// wurde. Vorher lag sie im try, und ein Lauf an der Schrittgrenze ging mit 0 Token
// und 0 USD in RunUsage - fuer das Tagesbudget unsichtbar.
var tally = new TokenTally();
try try
{ {
var tools = _toolRegistry.GetForAgent(agentConfig); var tools = _toolRegistry.GetForAgent(agentConfig);
@@ -198,7 +203,6 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
string? finalMessage = null; string? finalMessage = null;
var truncated = false; var truncated = false;
var tally = new TokenTally();
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model); var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
while (true) while (true)
@@ -315,7 +319,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var result = new AgentRunResult( var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Cancelled, null, agentConfig.AgentId, AgentRunStatus.Cancelled, null,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed); loopGuard.Steps, loopGuard.Tokens, sw.Elapsed) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result); OnRunCompleted?.Invoke(agentConfig.Model, result);
return result; return result;
} }
@@ -326,7 +330,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var result = new AgentRunResult( var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Cancelled, null, agentConfig.AgentId, AgentRunStatus.Cancelled, null,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed); loopGuard.Steps, loopGuard.Tokens, sw.Elapsed) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result); OnRunCompleted?.Invoke(agentConfig.Model, result);
return result; return result;
} }
@@ -338,7 +342,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var result = new AgentRunResult( var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, null, agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, null,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex); loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result); OnRunCompleted?.Invoke(agentConfig.Model, result);
return result; return result;
} }
@@ -349,7 +353,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var result = new AgentRunResult( var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Failed, null, agentConfig.AgentId, AgentRunStatus.Failed, null,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex); loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result); OnRunCompleted?.Invoke(agentConfig.Model, result);
return result; return result;
} }
@@ -441,6 +445,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(runCt, timeoutCts.Token); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(runCt, timeoutCts.Token);
var ct = linkedCts.Token; var ct = linkedCts.Token;
// #578: siehe RunCoreAsync - auch Abbrueche tragen ihre bezahlten Token.
var tally = new TokenTally();
try try
{ {
var tools = _toolRegistry.GetForAgent(agentConfig); var tools = _toolRegistry.GetForAgent(agentConfig);
@@ -472,7 +479,6 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
string? finalMessage = null; string? finalMessage = null;
var truncated = false; var truncated = false;
var tally = new TokenTally();
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model); var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
while (true) while (true)
@@ -587,7 +593,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
AddChatEntry(agentConfig.AgentId, "assistant", cancelMsg, source); AddChatEntry(agentConfig.AgentId, "assistant", cancelMsg, source);
var result = new AgentRunResult( var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Cancelled, cancelMsg, agentConfig.AgentId, AgentRunStatus.Cancelled, cancelMsg,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed); loopGuard.Steps, loopGuard.Tokens, sw.Elapsed) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result); OnRunCompleted?.Invoke(agentConfig.Model, result);
return result; return result;
} }
@@ -599,7 +605,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
AddChatEntry(agentConfig.AgentId, "assistant", limitMsg, source); AddChatEntry(agentConfig.AgentId, "assistant", limitMsg, source);
var result = new AgentRunResult( var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, limitMsg, agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, limitMsg,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex); loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result); OnRunCompleted?.Invoke(agentConfig.Model, result);
return result; return result;
} }
@@ -611,7 +617,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
AddChatEntry(agentConfig.AgentId, "assistant", errorMsg, source); AddChatEntry(agentConfig.AgentId, "assistant", errorMsg, source);
var result = new AgentRunResult( var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Failed, errorMsg, agentConfig.AgentId, AgentRunStatus.Failed, errorMsg,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex); loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
OnRunCompleted?.Invoke(agentConfig.Model, result); OnRunCompleted?.Invoke(agentConfig.Model, result);
return result; return result;
} }
@@ -1244,6 +1250,18 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found.")); return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found."));
} }
// #581: Unvollstaendiges Argument-JSON gar nicht erst ans Tool reichen. Vorher
// bekam das Modell den Parsertext ("Expected depth to be zero...") und
// wiederholte denselben Aufruf bis zur Schrittgrenze. Beobachtet bei sehr
// grossen Argumenten: Das JSON brach genau vor dem grossen Wert ab.
if (!string.IsNullOrWhiteSpace(arguments) && !IstVollstaendigesJson(arguments))
{
var hinweis = UnvollstaendigeArgumente(toolName, arguments, agentConfig.LoopGuard.MaxResponseTokens);
logger.LogWarning("Unvollstaendige Argumente fuer {Tool} ({Laenge} Zeichen)", toolName, arguments.Length);
await Audit(AuditStatus.Error, hinweis);
return JsonSerializer.Serialize(new { error = hinweis });
}
// Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt // Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt
// ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen. // ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
if (_stagingGate is not null) if (_stagingGate is not null)
@@ -1332,6 +1350,33 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
} }
} }
/// <summary>Ob die Argumente eines Werkzeugaufrufs vollstaendiges JSON sind (#581).</summary>
internal static bool IstVollstaendigesJson(string arguments)
{
try
{
using var _ = JsonDocument.Parse(arguments);
return true;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
/// Die Rueckmeldung ans Modell, wenn Argumente abgeschnitten ankamen (#581): was los
/// ist und was stattdessen zu tun ist - nicht der Text eines JSON-Parsers.
/// </summary>
internal static string UnvollstaendigeArgumente(string toolName, string arguments, int maxResponseTokens)
{
var ende = arguments.Length <= 40 ? arguments : "..." + arguments[^40..];
return $"Die Argumente fuer '{toolName}' sind kein vollstaendiges JSON ({arguments.Length} Zeichen, "
+ $"enden mit: {ende}). Meist war ein Argument zu gross fuer eine einzelne Antwort "
+ $"(Ausgabegrenze {maxResponseTokens} Tokens) und wurde abgeschnitten. Denselben Aufruf "
+ "nicht wiederholen - den Inhalt auf mehrere kleinere Aufrufe verteilen.";
}
private AgentToolContext BuildToolContext( private AgentToolContext BuildToolContext(
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct, AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct,
DelegationChain? chain = null) DelegationChain? chain = null)
+16 -3
View File
@@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text; using System.Text;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Logging; namespace ClawdDotNet.Core.Logging;
@@ -7,6 +8,10 @@ namespace ClawdDotNet.Core.Logging;
/// Schreibt Log-Einträge asynchron in datei- und datumsgetrennte Logfiles. /// Schreibt Log-Einträge asynchron in datei- und datumsgetrennte Logfiles.
/// Struktur: {LogDirectory}/{Datum}/{Modul}.log /// Struktur: {LogDirectory}/{Datum}/{Modul}.log
/// Thread-safe durch ConcurrentQueue + dediziertem Writer-Task. /// Thread-safe durch ConcurrentQueue + dediziertem Writer-Task.
///
/// <para>Verzeichnisse <c>0700</c>, Dateien <c>0600</c> (#575): Im Protokoll stehen Pfade,
/// Fehlermeldungen von Werkzeugen und Anfänge von Aufträgen. Vorher entstanden sie mit der
/// Standardmaske, auf der Test-VM also <c>0775</c>/<c>0664</c>.</para>
/// </summary> /// </summary>
public sealed class FileLogWriter : IAsyncDisposable public sealed class FileLogWriter : IAsyncDisposable
{ {
@@ -20,7 +25,7 @@ public sealed class FileLogWriter : IAsyncDisposable
public FileLogWriter(FileLoggerOptions options) public FileLogWriter(FileLoggerOptions options)
{ {
_options = options; _options = options;
Directory.CreateDirectory(_options.LogDirectory); AppPaths.EnsureDirectory(_options.LogDirectory);
_writerTask = Task.Run(ProcessQueueAsync); _writerTask = Task.Run(ProcessQueueAsync);
} }
@@ -73,7 +78,10 @@ public sealed class FileLogWriter : IAsyncDisposable
_options.LogDirectory, _options.LogDirectory,
entry.Timestamp.ToString(_options.DateFormat)); entry.Timestamp.ToString(_options.DateFormat));
Directory.CreateDirectory(dateDir); // Nur beim Anlegen einschränken: EnsureDirectory setzt die Rechte, und das je
// Zeile zu tun hiesse einen Systemaufruf je Protokolleintrag.
if (!Directory.Exists(dateDir))
AppPaths.EnsureDirectory(dateDir);
var safeModule = SanitizeModuleName(entry.Module); var safeModule = SanitizeModuleName(entry.Module);
var filePath = Path.Combine(dateDir, $"{safeModule}.log"); var filePath = Path.Combine(dateDir, $"{safeModule}.log");
@@ -83,7 +91,12 @@ public sealed class FileLogWriter : IAsyncDisposable
lock (fileLock) lock (fileLock)
{ {
var line = entry.Format(_options.TimestampFormat) + Environment.NewLine; var line = entry.Format(_options.TimestampFormat) + Environment.NewLine;
File.AppendAllText(filePath, line, Encoding.UTF8);
// Wie vorher File.AppendAllText mit UTF-8: Die Präambel steht nur am Anfang
// einer neuen Datei, weil der StreamWriter sie nur bei Position 0 schreibt.
using var stream = OwnerOnlyFile.OpenAppend(filePath);
using var writer = new StreamWriter(stream, Encoding.UTF8);
writer.Write(line);
} }
} }
+6 -1
View File
@@ -139,8 +139,13 @@ public static class AtomicFile
return (fullPath, tempPath); return (fullPath, tempPath);
} }
/// <summary>
/// Die Nebendatei ersetzt die Zieldatei — und mit ihr deren Rechte. Deshalb entsteht sie
/// unter Unix mit <c>0600</c> (#575); mit der Standardmaske wurde aus einer geschützten
/// <c>InstanceSettings.json</c> beim ersten Speichern eine für die Gruppe lesbare.
/// </summary>
private static FileStream CreateTempStream(string tempPath) private static FileStream CreateTempStream(string tempPath)
=> new(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); => new(tempPath, OwnerOnlyFile.Options(FileMode.CreateNew, FileAccess.Write, FileShare.None));
/// <summary>Versuche für das Ersetzen — siehe <see cref="Commit"/>.</summary> /// <summary>Versuche für das Ersetzen — siehe <see cref="Commit"/>.</summary>
private const int CommitAttempts = 20; private const int CommitAttempts = 20;
@@ -0,0 +1,35 @@
namespace ClawdDotNet.Core.Storage;
/// <summary>
/// Legt Dateien so an, dass sie unter Unix von Anfang an nur dem Besitzer gehören (#575).
///
/// <para><b>Warum beim Anlegen und nicht hinterher.</b> .NET erzeugt neue Dateien mit
/// <c>0666 &amp; ~umask</c> — auf Ubuntu mit der Maske <c>0002</c> also <c>0664</c>. Ein
/// nachträgliches <see cref="AppPaths.RestrictToOwner"/> lässt ein Fenster offen und
/// wird an jeder neuen Schreibstelle vergessen: <c>InstanceSettings.json</c> ging auf der
/// Test-VM beim ersten Speichern von <c>0600</c> auf <c>0664</c>.</para>
///
/// <para>Unter Windows wirkungslos; dort regelt die Vererbung aus dem Profil die Rechte.</para>
/// </summary>
public static class OwnerOnlyFile
{
private const UnixFileMode OwnerReadWrite = UnixFileMode.UserRead | UnixFileMode.UserWrite;
/// <summary>Öffnungsangaben, die eine neu entstehende Datei auf <c>0600</c> setzen.</summary>
public static FileStreamOptions Options(FileMode mode, FileAccess access, FileShare share)
{
var options = new FileStreamOptions { Mode = mode, Access = access, Share = share };
if (!OperatingSystem.IsWindows())
options.UnixCreateMode = OwnerReadWrite;
return options;
}
/// <summary>
/// Öffnet zum Anhängen. Eine vorhandene Datei behält ihre Rechte — nur was hier neu
/// entsteht, gehört dem Besitzer allein.
/// </summary>
public static FileStream OpenAppend(string path)
=> new(path, Options(FileMode.Append, FileAccess.Write, FileShare.Read));
}
@@ -39,6 +39,16 @@ public sealed class SqliteStorage
}.ToString(); }.ToString();
Initialize(); Initialize();
// #575: SQLite legt die Datenbank mit der Standardmaske an (auf der Test-VM 0644),
// und WAL- und Shared-Memory-Datei erben deren Rechte. Darin stehen Chatverläufe,
// Gedächtnis und die Argumente jedes Werkzeugaufrufs. Auch für bestehende Dateien,
// die noch aus der Zeit vor dieser Zeile stammen.
foreach (var datei in new[] { databasePath, databasePath + "-wal", databasePath + "-shm" })
{
if (File.Exists(datei))
AppPaths.RestrictToOwner(datei);
}
} }
public SqliteConnection OpenConnection() public SqliteConnection OpenConnection()
@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Scheduling;
using ClawdDotNet.Core.State; using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -28,6 +29,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
private readonly ToolRegistry _toolRegistry; private readonly ToolRegistry _toolRegistry;
private readonly IStateStore _stateStore; private readonly IStateStore _stateStore;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly TimeProvider _clock;
public EngineTaskDispatcher( public EngineTaskDispatcher(
AgentEngine engine, AgentEngine engine,
@@ -35,7 +37,8 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
string instanceId, string instanceId,
ToolRegistry toolRegistry, ToolRegistry toolRegistry,
IStateStore stateStore, IStateStore stateStore,
ILoggerFactory loggerFactory) ILoggerFactory loggerFactory,
TimeProvider? clock = null)
{ {
_engine = engine; _engine = engine;
_agents = agents; _agents = agents;
@@ -43,6 +46,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_toolRegistry = toolRegistry; _toolRegistry = toolRegistry;
_stateStore = stateStore; _stateStore = stateStore;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Dispatcher"); _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Dispatcher");
_clock = clock ?? TimeProvider.System;
} }
public async Task<bool> DispatchAsync(TaskItem task, CancellationToken ct) public async Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
@@ -148,11 +152,16 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
return agents.Count == 1 ? agents[0] : null; return agents.Count == 1 ? agents[0] : null;
} }
private static string BuildMessage(TaskItem task) internal string BuildMessage(TaskItem task)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}"); sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}");
// #589: Weder Systemprompt noch Auftrag nannten bisher die Zeit. Fast jede
// wiederkehrende Aufgabe braucht sie (Dateiname je Tag, "Bericht ueber gestern"),
// und ein Modell ohne diese Zeile raet aus seinem Trainingsstand.
sb.AppendLine(Zeitangabe(task, _clock.GetUtcNow()));
if (!string.IsNullOrWhiteSpace(task.Body)) if (!string.IsNullOrWhiteSpace(task.Body))
{ {
sb.AppendLine(); sb.AppendLine();
@@ -168,4 +177,33 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
return sb.ToString().TrimEnd(); return sb.ToString().TrimEnd();
} }
/// <summary>
/// Aktuelle Zeit in der Zeitzone der Aufgabe (sonst der des Systems), dazu UTC und -
/// bei wiederkehrenden Aufgaben - der Termin selbst.
/// </summary>
internal static string Zeitangabe(TaskItem task, DateTimeOffset jetzt)
{
var zoneId = string.IsNullOrWhiteSpace(task.When?.TimeZone) ? TimeZones.LocalIanaId : task.When!.TimeZone;
TimeZoneInfo zone;
try
{
zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
zone = TimeZoneInfo.Local;
zoneId = TimeZones.LocalIanaId;
}
var lokal = TimeZoneInfo.ConvertTime(jetzt, zone);
var invariant = System.Globalization.CultureInfo.InvariantCulture;
var zeile = string.Format(invariant,
"Aktuelle Zeit: {0:dd.MM.yyyy HH:mm} Uhr ({1}, UTC{0:zzz}); in UTC {2:yyyy-MM-ddTHH:mm}Z.",
lokal, zoneId, jetzt.UtcDateTime);
return task.When is { } when
? zeile + $" Termin der Aufgabe: {when.Kind.ToString().ToLowerInvariant()} \"{when.Value}\"."
: zeile;
}
} }
+52
View File
@@ -46,6 +46,56 @@ public partial class App : Application
/// </summary> /// </summary>
private bool _managed; private bool _managed;
// #576: Muessen leben, solange die Anwendung laeuft - eine eingesammelte
// Registrierung meldet sich ab.
private System.Runtime.InteropServices.PosixSignalRegistration? _sigterm;
private System.Runtime.InteropServices.PosixSignalRegistration? _sigint;
/// <summary>
/// SIGTERM und SIGINT beenden auch mit Fenster geordnet (#576).
///
/// <para>Kopflos war das seit HeadlessRun verdrahtet, mit Fenster nicht: Unter Linux
/// reagierte die Instanz auf SIGTERM gar nicht und wurde nach Ablauf jeder Frist hart
/// abgeschossen - beim Abmelden, bei systemctl stop, beim Herunterfahren. Derselbe Weg
/// wie die Bitte des Instanzmanagers: das Fenster ohne Rueckfrage schliessen, das
/// geordnete Ende laeuft von dort.</para>
/// </summary>
private void RegisterSignals(IClassicDesktopStyleApplicationLifetime desktop)
{
void Beenden(System.Runtime.InteropServices.PosixSignalContext context)
{
// Abfangen, nicht durchlassen: Sonst raeumte die Laufzeit den Prozess ab,
// waehrend das geordnete Ende noch laeuft.
context.Cancel = true;
var signal = context.Signal.ToString();
Dispatcher.UIThread.Post(() =>
{
_host?.LoggerFactory.CreateLogger("ClawdDotNet.Shutdown")
.LogInformation("Signal {Signal} empfangen - die Instanz endet geordnet.", signal);
if (_host is null)
desktop.Shutdown();
else if (desktop.MainWindow is MainWindow fenster)
fenster.BeendenOhneRueckfrage();
else
_ = BeendeGeordnetAsync(desktop, signal);
});
}
try
{
_sigterm = System.Runtime.InteropServices.PosixSignalRegistration.Create(
System.Runtime.InteropServices.PosixSignal.SIGTERM, Beenden);
_sigint = System.Runtime.InteropServices.PosixSignalRegistration.Create(
System.Runtime.InteropServices.PosixSignal.SIGINT, Beenden);
}
catch (PlatformNotSupportedException)
{
// Ohne Signale bleibt es beim Schliessen ueber Fenster und Manager.
}
}
public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
@@ -71,6 +121,8 @@ public partial class App : Application
if (_host is not null) await _host.DisposeAsync(); if (_host is not null) await _host.DisposeAsync();
}; };
RegisterSignals(desktop);
// Nicht abwarten: OnFrameworkInitializationCompleted muss zurückkehren, // Nicht abwarten: OnFrameworkInitializationCompleted muss zurückkehren,
// damit die Nachrichtenschleife anläuft — sonst gäbe es keinen Faden, auf // damit die Nachrichtenschleife anläuft — sonst gäbe es keinen Faden, auf
// dem die Fenster des Startvorgangs überhaupt erscheinen könnten. // dem die Fenster des Startvorgangs überhaupt erscheinen könnten.
@@ -1,8 +1,8 @@
// ClawdDotNet — Interne Agenten-Kommunikation A2A (Vorhaben #208) // ClawdDotNet — Interne Agenten-Kommunikation A2A (Vorhaben #208)
// //
// Analyse und Protokollierung des Nachrichtenaustauschs zwischen Agenten. // Analyse und Protokollierung des Nachrichtenaustauschs zwischen Agenten.
// - Filterbar nach beteiligten Agenten // - Filterbar nach beteiligtem Agenten, Zeitraum und Volltext
// - Dialog-Timeline zwischen Quelle und Ziel // - Jede Zeile trägt Absender ➔ Empfänger — auch Antworten (#536)
// - Live-Aktualisierung über Server-Sent Events (SSE "chat" mit source === "agentcomm") // - Live-Aktualisierung über Server-Sent Events (SSE "chat" mit source === "agentcomm")
// Strikt DOM API, kein innerHTML. // Strikt DOM API, kein innerHTML.
@@ -19,6 +19,30 @@ import {
let sseAbmeldung = null; let sseAbmeldung = null;
const PRAEFIX = "[Nachricht von Agent \"";
/**
* Liest aus "[Nachricht von Agent "Name" (id)]\n\nText" Kennung, Name und Text — dieselbe
* Regel wie AgentCommunicationBuilder.LeseAbsender auf dem Server (nur für Live-Ereignisse).
*/
function leseAbsender(content) {
if (!content.startsWith(PRAEFIX)) return { id: null, name: null, inhalt: content };
let name = null;
let id = null;
const endQuote = content.indexOf('"', PRAEFIX.length);
if (endQuote > PRAEFIX.length) {
name = content.slice(PRAEFIX.length, endQuote);
const openParen = content.indexOf("(", endQuote);
const closeParen = openParen < 0 ? -1 : content.indexOf(")", openParen);
if (openParen > 0 && closeParen > openParen) {
id = content.slice(openParen + 1, closeParen);
}
}
const doubleNl = content.indexOf("\n\n");
return { id, name, inhalt: doubleNl > 0 ? content.slice(doubleNl + 2) : content };
}
export async function rendereAgentenChats() { export async function rendereAgentenChats() {
const container = document.getElementById("ansicht-inhalt"); const container = document.getElementById("ansicht-inhalt");
if (!container) return; if (!container) return;
@@ -44,10 +68,26 @@ export async function rendereAgentenChats() {
const agentSelect = erstelleElement("select", { const agentSelect = erstelleElement("select", {
klasse: "eingabe", klasse: "eingabe",
stil: { minWidth: "180px" }, stil: { minWidth: "180px" },
title: "Nach Agent filtern" title: "Nach beteiligtem Agenten filtern (Absender oder Empfänger)"
});
agentSelect.append(erstelleElement("option", { value: "", text: "Alle Agenten" }));
const vonEingabe = erstelleElement("input", {
klasse: "eingabe",
type: "date",
title: "Nachrichten ab diesem Tag"
});
const bisEingabe = erstelleElement("input", {
klasse: "eingabe",
type: "date",
title: "Nachrichten bis einschließlich diesem Tag"
});
const suchEingabe = erstelleElement("input", {
klasse: "eingabe",
type: "search",
placeholder: "Volltext…",
title: "Suche im Nachrichtentext und in den Agentennamen"
}); });
const optAlle = erstelleElement("option", { value: "", text: "Alle Agenten" });
agentSelect.append(optAlle);
const aktualisierenBtn = erstelleElement( const aktualisierenBtn = erstelleElement(
"button", "button",
@@ -60,7 +100,15 @@ export async function rendereAgentenChats() {
erstelleElement("span", { text: " Aktualisieren" }) erstelleElement("span", { text: " Aktualisieren" })
); );
filterGruppe.append(agentSelect, aktualisierenBtn); filterGruppe.append(
agentSelect,
erstelleElement("span", { klasse: "gedaempft", text: "von" }),
vonEingabe,
erstelleElement("span", { klasse: "gedaempft", text: "bis" }),
bisEingabe,
suchEingabe,
aktualisierenBtn
);
kopf.append(titelGruppe, filterGruppe); kopf.append(titelGruppe, filterGruppe);
layout.append(kopf); layout.append(kopf);
@@ -81,37 +129,41 @@ export async function rendereAgentenChats() {
const infoKarte = erstelleKarte("Interne Kommunikation", [ const infoKarte = erstelleKarte("Interne Kommunikation", [
erstelleElement("p", { erstelleElement("p", {
klasse: "gedaempft", klasse: "gedaempft",
text: "Hier wird der direkte Nachrichtenaustausch zwischen Agenten (A2A) protokolliert, wenn Agenten über das Werkzeug 'SendMessage' miteinander interagieren." text: "Hier wird der direkte Nachrichtenaustausch zwischen Agenten protokolliert, wenn Agenten über das Werkzeug AgentComm (Aktion send_message) miteinander sprechen."
}) })
]); ]);
layout.append(infoKarte); layout.append(infoKarte);
// Verlauf-Container // Verlauf-Container
const trefferZeile = erstelleElement("p", { klasse: "gedaempft text-klein", text: "" });
const verlaufContainer = erstelleElement("div", { klasse: "a2a-timeline" }); const verlaufContainer = erstelleElement("div", { klasse: "a2a-timeline" });
layout.append(verlaufContainer); layout.append(trefferZeile, verlaufContainer);
container.append(layout); container.append(layout);
let alleNachrichten = []; let alleNachrichten = [];
let agentenListe = []; let agentenListe = [];
// Wer hat einen Agenten zuletzt angeschrieben? Für die Empfänger live eintreffender Antworten.
const letzterFragender = new Map();
// Agenten laden für Filter // Agenten laden für Filter
try { try {
const agenten = await api("/agents"); const agenten = await api("/agents");
agentenListe = Array.isArray(agenten) ? agenten : []; agentenListe = Array.isArray(agenten) ? agenten : [];
for (const a of agentenListe) { for (const a of agentenListe) {
const opt = erstelleElement("option", { agentSelect.append(erstelleElement("option", {
value: a.agentId, value: a.agentId,
text: a.displayName || a.agentId text: a.displayName || a.agentId
}); }));
agentSelect.append(opt);
} }
} catch { } catch {
// Wenn Agenten nicht ladbar sind, Filter leer lassen // Wenn Agenten nicht ladbar sind, Filter leer lassen
} }
agentSelect.addEventListener("change", () => { for (const eingabe of [agentSelect, vonEingabe, bisEingabe]) {
rendereNachrichten(); eingabe.addEventListener("change", () => rendereNachrichten());
}); }
suchEingabe.addEventListener("input", () => rendereNachrichten());
async function ladeVerlauf() { async function ladeVerlauf() {
verlaufContainer.replaceChildren( verlaufContainer.replaceChildren(
@@ -121,6 +173,11 @@ export async function rendereAgentenChats() {
try { try {
const daten = await api("/agents/communications"); const daten = await api("/agents/communications");
alleNachrichten = Array.isArray(daten) ? daten : []; alleNachrichten = Array.isArray(daten) ? daten : [];
for (const m of alleNachrichten) {
if (m.role === "user" && m.sourceAgentId) {
letzterFragender.set(m.targetAgentId, { id: m.sourceAgentId, name: m.sourceAgentName });
}
}
rendereNachrichten(); rendereNachrichten();
} catch (err) { } catch (err) {
verlaufContainer.replaceChildren( verlaufContainer.replaceChildren(
@@ -134,23 +191,41 @@ export async function rendereAgentenChats() {
} }
} }
function passtZumFilter(m) {
const agent = agentSelect.value;
if (agent && m.targetAgentId !== agent && m.sourceAgentId !== agent) return false;
// Die Datumsfelder liefern "JJJJ-MM-TT" im Ortskalender; verglichen wird der lokale Tag.
const zeit = new Date(m.timestamp);
if (vonEingabe.value && zeit < new Date(`${vonEingabe.value}T00:00:00`)) return false;
if (bisEingabe.value && zeit > new Date(`${bisEingabe.value}T23:59:59.999`)) return false;
const suche = suchEingabe.value.trim().toLowerCase();
if (suche) {
const heuhaufen = [
m.content, m.sourceAgentName, m.sourceAgentId, m.targetAgentName, m.targetAgentId
].filter(Boolean).join(" ").toLowerCase();
if (!heuhaufen.includes(suche)) return false;
}
return true;
}
function rendereNachrichten() { function rendereNachrichten() {
const gewaehlterAgent = agentSelect.value; const gefiltert = alleNachrichten.filter(passtZumFilter);
const gefiltert = gewaehlterAgent const filterAktiv = agentSelect.value || vonEingabe.value || bisEingabe.value || suchEingabe.value.trim();
? alleNachrichten.filter(
(m) => trefferZeile.textContent = filterAktiv
m.targetAgentId === gewaehlterAgent || ? `${gefiltert.length} von ${alleNachrichten.length} Nachrichten`
m.sourceAgentId === gewaehlterAgent : `${alleNachrichten.length} Nachrichten`;
)
: alleNachrichten;
if (gefiltert.length === 0) { if (gefiltert.length === 0) {
verlaufContainer.replaceChildren( verlaufContainer.replaceChildren(
erstelleKarte("Keine Nachrichten", [ erstelleKarte("Keine Nachrichten", [
erstelleElement("p", { erstelleElement("p", {
klasse: "gedaempft", klasse: "gedaempft",
text: gewaehlterAgent text: filterAktiv
? "Für den ausgewählten Agenten liegt noch keine interne Kommunikation vor." ? "Für diese Filter liegt keine interne Kommunikation vor."
: "In dieser Instanz wurde bisher keine interne Agent-zu-Agent-Kommunikation protokolliert." : "In dieser Instanz wurde bisher keine interne Agent-zu-Agent-Kommunikation protokolliert."
}) })
]) ])
@@ -158,12 +233,7 @@ export async function rendereAgentenChats() {
return; return;
} }
verlaufContainer.replaceChildren(); verlaufContainer.replaceChildren(...gefiltert.map(erstelleNachrichtElement));
for (const m of gefiltert) {
const eintrag = erstelleNachrichtElement(m);
verlaufContainer.append(eintrag);
}
} }
function erstelleNachrichtElement(m) { function erstelleNachrichtElement(m) {
@@ -175,22 +245,14 @@ export async function rendereAgentenChats() {
const kopf = erstelleElement("div", { klasse: "a2a-eintrag-kopf" }); const kopf = erstelleElement("div", { klasse: "a2a-eintrag-kopf" });
const absenderGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" }); const absenderGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
if (istAntwort) { const quelleName = m.sourceAgentName || m.sourceAgentId || "unbekannt";
// Antwort vom Zielagenten const zielName = m.targetAgentName || m.targetAgentId || "unbekannt";
absenderGruppe.append( absenderGruppe.append(
erstellePlakette(m.targetAgentName || m.targetAgentId, "erfolg"), erstellePlakette(quelleName, istAntwort ? "erfolg" : "neutral"),
erstelleElement("span", { klasse: "a2a-pfeil", text: "➔ Antwort" }) erstelleElement("span", { klasse: "a2a-pfeil", text: istAntwort ? "➔ Antwort an" : "➔" }),
erstellePlakette(zielName, istAntwort ? "neutral" : "primaer")
); );
} else {
// Anfrage/Nachricht von Quelle an Ziel
const quelleName = m.sourceAgentName || m.sourceAgentId || "Agent";
const zielName = m.targetAgentName || m.targetAgentId;
absenderGruppe.append(
erstellePlakette(quelleName, "neutral"),
erstelleElement("span", { klasse: "a2a-pfeil", text: "➔" }),
erstellePlakette(zielName, "primaer")
);
}
const zeit = erstelleElement("span", { const zeit = erstelleElement("span", {
klasse: "a2a-zeit", klasse: "a2a-zeit",
@@ -210,57 +272,41 @@ export async function rendereAgentenChats() {
// SSE Live-Aktualisierung // SSE Live-Aktualisierung
sseAbmeldung = abonnieren("chat", (daten) => { sseAbmeldung = abonnieren("chat", (daten) => {
if (daten?.source === "agentcomm") { if (daten?.source !== "agentcomm") return;
// Neue Nachricht kam live herein: an die Liste anhängen
const zielAgent = agentenListe.find((a) => a.agentId === daten.agentId);
const zielName = zielAgent ? zielAgent.displayName : daten.agentId;
let senderId = null; const agent = agentenListe.find((a) => a.agentId === daten.agentId);
let senderName = null; const agentName = agent ? agent.displayName : daten.agentId;
let content = daten.content || ""; let neueNachricht;
if (daten.role === "user" && content.startsWith("[Nachricht von Agent \"")) { if (daten.role === "user") {
const endQuote = content.indexOf('"', 22); const { id, name, inhalt } = leseAbsender(daten.content || "");
if (endQuote > 22) { if (id) letzterFragender.set(daten.agentId, { id, name });
senderName = content.slice(22, endQuote); neueNachricht = {
const openParen = content.indexOf("(", endQuote);
const closeParen = content.indexOf(")", openParen);
if (openParen > 0 && closeParen > openParen) {
senderId = content.slice(openParen + 1, closeParen);
}
}
const doubleNl = content.indexOf("\n\n");
if (doubleNl > 0) {
content = content.slice(doubleNl + 2);
}
}
const neueNachricht = {
targetAgentId: daten.agentId, targetAgentId: daten.agentId,
targetAgentName: zielName, targetAgentName: agentName,
sourceAgentId: senderId, sourceAgentId: id,
sourceAgentName: senderName, sourceAgentName: name,
role: daten.role, role: daten.role,
content: content, content: inhalt,
timestamp: daten.timestamp || new Date().toISOString() timestamp: daten.timestamp || new Date().toISOString()
}; };
} else if (daten.role === "assistant") {
const fragender = letzterFragender.get(daten.agentId);
neueNachricht = {
targetAgentId: fragender?.id || "",
targetAgentName: fragender?.name || fragender?.id || "",
sourceAgentId: daten.agentId,
sourceAgentName: agentName,
role: daten.role,
content: daten.content || "",
timestamp: daten.timestamp || new Date().toISOString()
};
} else {
return;
}
alleNachrichten.push(neueNachricht); alleNachrichten.push(neueNachricht);
rendereNachrichten();
const gewaehlterAgent = agentSelect.value;
if (
!gewaehlterAgent ||
neueNachricht.targetAgentId === gewaehlterAgent ||
neueNachricht.sourceAgentId === gewaehlterAgent
) {
// Falls vorher "Keine Nachrichten" da stand:
const leerKarte = verlaufContainer.querySelector(".karte");
if (leerKarte) verlaufContainer.replaceChildren();
const el = erstelleNachrichtElement(neueNachricht);
verlaufContainer.append(el);
}
}
}); });
ladeVerlauf(); ladeVerlauf();
@@ -215,7 +215,7 @@ async function rendereDetails(agentId) {
const zeile = erstelleElement("div", { klasse: "werkzeug-zeile" }); const zeile = erstelleElement("div", { klasse: "werkzeug-zeile" });
const links = erstelleElement("div", { klasse: "werkzeug-info" }); const links = erstelleElement("div", { klasse: "werkzeug-info" });
const toolTitel = erstelleElement("strong", { text: tool.toolName }); const toolTitel = erstelleElement("strong", { text: tool.name });
const toolDesc = erstelleElement("div", { const toolDesc = erstelleElement("div", {
klasse: "werkzeug-beschreibung gedaempft", klasse: "werkzeug-beschreibung gedaempft",
text: tool.description || "" text: tool.description || ""
@@ -229,7 +229,7 @@ async function rendereDetails(agentId) {
{ {
klasse: "schaltflaeche flach klein", klasse: "schaltflaeche flach klein",
title: "Werkzeug-Einstellungen einsehen", title: "Werkzeug-Einstellungen einsehen",
onclick: () => zeigeWerkzeugEinstellungen(agent.agentId, tool.toolName) onclick: () => zeigeWerkzeugEinstellungen(agent.agentId, tool.name)
}, },
erstelleIcon(PFADE.einstellungen, 14), erstelleIcon(PFADE.einstellungen, 14),
erstelleElement("span", { text: " Konfiguration" }) erstelleElement("span", { text: " Konfiguration" })
@@ -243,11 +243,11 @@ async function rendereDetails(agentId) {
const aktiv = e.target.checked; const aktiv = e.target.checked;
try { try {
await api( await api(
`/agents/${encodeURIComponent(agent.agentId)}/tools/${encodeURIComponent(tool.toolName)}?enabled=${aktiv}`, `/agents/${encodeURIComponent(agent.agentId)}/tools/${encodeURIComponent(tool.name)}?enabled=${aktiv}`,
{ method: "PUT" } { method: "PUT" }
); );
tool.enabled = aktiv; tool.enabled = aktiv;
zeigeMeldung(`Werkzeug '${tool.toolName}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info"); zeigeMeldung(`Werkzeug '${tool.name}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
} catch (err) { } catch (err) {
e.target.checked = !aktiv; e.target.checked = !aktiv;
zeigeMeldung(`Änderung fehlgeschlagen: ${err.message}`, "gefahr"); zeigeMeldung(`Änderung fehlgeschlagen: ${err.message}`, "gefahr");
@@ -24,14 +24,25 @@ let agentenSelector = null;
let statusPlakette = null; let statusPlakette = null;
let entladeEreignisse = []; let entladeEreignisse = [];
// #592: Zählt die Aufbauten dieser Ansicht. Ein Aufbau, der während seines await von
// einem neueren überholt wurde, meldet keine Ereignisse mehr an — sonst hingen zwei
// "chat"-Listener und jede Nachricht erschien doppelt.
let aufbauZaehler = 0;
function raeumeEreignisseAuf() {
entladeEreignisse.forEach((cleanup) => cleanup());
entladeEreignisse = [];
}
export async function rendereChat(parameter = {}) { export async function rendereChat(parameter = {}) {
const container = document.getElementById("ansicht-inhalt"); const container = document.getElementById("ansicht-inhalt");
if (!container) return; if (!container) return;
container.replaceChildren(); container.replaceChildren();
const dieserAufbau = ++aufbauZaehler;
// Alte Ereignisse aufräumen // Alte Ereignisse aufräumen
entladeEreignisse.forEach((cleanup) => cleanup()); raeumeEreignisseAuf();
entladeEreignisse = [];
const chatShell = erstelleElement("div", { klasse: "chat-ansicht" }); const chatShell = erstelleElement("div", { klasse: "chat-ansicht" });
@@ -128,6 +139,12 @@ export async function rendereChat(parameter = {}) {
// Lade Agenten und initialisiere // Lade Agenten und initialisiere
await ladeAgenten(parameter.agentId); await ladeAgenten(parameter.agentId);
// Während des Ladens neu aufgebaut? Dann gehört die Ansicht dem neueren Aufbau.
if (dieserAufbau !== aufbauZaehler) return;
// Falls zwischendurch doch etwas angemeldet wurde: vorher abräumen, nie doppelt.
raeumeEreignisseAuf();
// SSE-Abonnements // SSE-Abonnements
entladeEreignisse.push( entladeEreignisse.push(
abonnieren("chat", (daten) => { abonnieren("chat", (daten) => {
@@ -203,7 +220,12 @@ async function waehleAgent(agentId) {
const agent = agentenListe.find((a) => a.agentId === agentId); const agent = agentenListe.find((a) => a.agentId === agentId);
aktualisiereLaufStatus(agent?.isRunning ?? false); aktualisiereLaufStatus(agent?.isRunning ?? false);
window.location.hash = `#/chat/${encodeURIComponent(agentId)}`; // Adresse mitführen, ohne hashchange auszulösen (#592): Ein Setzen von location.hash
// ließ den Router die ganze Ansicht mitten im Laden ein zweites Mal aufbauen.
const ziel = `#/chat/${encodeURIComponent(agentId)}`;
if (window.location.hash !== ziel) {
history.replaceState(null, "", ziel);
}
await ladeChatVerlauf(agentId); await ladeChatVerlauf(agentId);
} }
@@ -179,9 +179,19 @@ function starteLogStream() {
}); });
} }
function fuegeLogZeileEin(eintrag) { function fuegeLogZeileEin(roh) {
if (!logContainer) return; if (!logContainer) return;
// Seit #591 kommen die Felder klein geschrieben (module/level/text). Instanzen einer
// älteren Fassung senden sie noch groß — beide Schreibweisen annehmen, sonst zeigt
// die Ansicht bei gemischten Fassungen wieder nur den Instanznamen.
const eintrag = {
...roh,
module: roh.module ?? roh.Module,
level: roh.level ?? roh.Level,
text: roh.text ?? roh.Text
};
// Filter nach Instanz falls gewählt // Filter nach Instanz falls gewählt
if (instanzFilter && eintrag.instanz && eintrag.instanz !== instanzFilter && eintrag.instanzName !== instanzFilter) { if (instanzFilter && eintrag.instanz && eintrag.instanz !== instanzFilter && eintrag.instanzName !== instanzFilter) {
return; return;
@@ -111,22 +111,24 @@ async function ladeFreigaben() {
erstelleElement("dt", { text: "Agent" }), erstelleElement("dt", { text: "Agent" }),
erstelleElement("dd", { text: anfrage.agentId || "—" }), erstelleElement("dd", { text: anfrage.agentId || "—" }),
erstelleElement("dt", { text: "Werkzeug" }), erstelleElement("dt", { text: "Werkzeug" }),
erstelleElement("dd", { text: `${anfrage.toolName} → ${anfrage.methodName}` }), erstelleElement("dd", { text: `${anfrage.tool} → ${anfrage.action || "—"}` }),
erstelleElement("dt", { text: "Zeitpunkt" }), erstelleElement("dt", { text: "Zeitpunkt" }),
erstelleElement("dd", { text: formatiereDatumZeit(anfrage.requestedAt) }) erstelleElement("dd", { text: formatiereDatumZeit(anfrage.createdAt) })
); );
const argBox = erstelleElement("div", { klasse: "staging-argumente" }); const argBox = erstelleElement("div", { klasse: "staging-argumente" });
argBox.append(erstelleElement("strong", { text: "Eingefrorene Argumente:" })); argBox.append(erstelleElement("strong", { text: "Eingefrorene Argumente:" }));
// Strikt textContent! Argumente können Fremdtext sein // Strikt textContent! Argumente können Fremdtext sein.
// Die API liefert sie als rohe Zeichenkette (argumentsJson) — genau das, was bei
// Freigabe ausgeführt würde. Lesbar eingerückt, wenn es gültiges JSON ist; sonst roh.
const pre = erstelleElement("pre", { const pre = erstelleElement("pre", {
klasse: "code-block", klasse: "code-block",
text: JSON.stringify(anfrage.arguments, null, 2) text: formatiereArgumente(anfrage.argumentsJson)
}); });
argBox.append(pre); argBox.append(pre);
const karte = erstelleKarte(`${anfrage.toolName}.${anfrage.methodName}`, [datenliste, argBox], { const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, [datenliste, argBox], {
aktionen: kopfAktionen aktionen: kopfAktionen
}); });
@@ -144,6 +146,19 @@ async function ladeFreigaben() {
} }
} }
/**
* Die eingefrorenen Argumente lesbar machen, ohne sie zu verändern: eingerückt, wenn es
* gültiges JSON ist, sonst genau die Zeichenkette, die ausgeführt würde.
*/
function formatiereArgumente(argumentsJson) {
if (!argumentsJson) return "(keine)";
try {
return JSON.stringify(JSON.parse(argumentsJson), null, 2);
} catch {
return argumentsJson;
}
}
function aktualisiereStagingBadge(anzahl) { function aktualisiereStagingBadge(anzahl) {
const badge = document.getElementById("staging-badge"); const badge = document.getElementById("staging-badge");
if (!badge) return; if (!badge) return;
@@ -91,6 +91,10 @@ async function ladeAktivenBereich() {
if (!container) return; if (!container) return;
container.replaceChildren(); container.replaceChildren();
// "Neuer Job" gehoert nur zum Jobs-Reiter; im Taskboard legte er trotzdem einen Job an (#591).
const neuerJob = document.querySelector(".aufgaben-ansicht .ansicht-kopf .schaltflaeche.primaer");
if (neuerJob) neuerJob.hidden = aktiverReiter !== "jobs";
if (aktiverReiter === "jobs") { if (aktiverReiter === "jobs") {
await rendereJobs(container); await rendereJobs(container);
} else { } else {
@@ -369,7 +373,13 @@ async function rendereTaskboard(container) {
for (const task of tasks) { for (const task of tasks) {
const karte = erstelleKarte(task.title || `Aufgabe ${task.id}`, [ const karte = erstelleKarte(task.title || `Aufgabe ${task.id}`, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: `ID: ${task.id} · Agent: ${task.agentId || "—"}` }), erstelleElement("p", { klasse: "gedaempft text-klein", text: `ID: ${task.id} · Agent: ${task.assignee || "—"}` }),
erstelleElement("p", {
klasse: "gedaempft text-klein",
text: task.when
? `Termin: ${task.when.kind} ${task.when.value} (${task.when.timeZone || "ohne Zeitzone"})`
: "Termin: einmalig"
}),
erstelleElement("div", { erstelleElement("div", {
klasse: "task-status-zeile", klasse: "task-status-zeile",
stil: "margin-top: 8px;" stil: "margin-top: 8px;"
@@ -1,6 +1,10 @@
// ClawdDotNet — Token-Verbrauch & Analyse // ClawdDotNet — Token-Verbrauch & Analyse
// //
// Diagramme (CSS/DOM-Balken ohne externe Bibliothek), Statistiken und Läufetabelle. // Diagramme (CSS/DOM-Balken ohne externe Bibliothek), Statistiken und Läufetabelle.
//
// Feldnamen wie UsageDto (#591): costUsd, costIsKnown, occurredAt, status. Eine
// Gesamtsumme liefert die API nicht — sie ist Prompt + Completion. Vorher las die Ansicht
// totalTokens, cost und timestamp und zeigte überall 0 bzw. "—".
import { api } from "../modules/api.js"; import { api } from "../modules/api.js";
import { import {
@@ -26,6 +30,10 @@ export async function rendereVerbrauch() {
await ladeVerbrauch(); await ladeVerbrauch();
} }
function gesamtTokens(lauf) {
return (lauf.promptTokens || 0) + (lauf.completionTokens || 0);
}
async function ladeVerbrauch() { async function ladeVerbrauch() {
const container = document.getElementById("verbrauch-inhalt-bereich"); const container = document.getElementById("verbrauch-inhalt-bereich");
if (!container) return; if (!container) return;
@@ -55,32 +63,45 @@ async function ladeVerbrauch() {
let summePrompt = 0; let summePrompt = 0;
let summeCompletion = 0; let summeCompletion = 0;
let summeKosten = 0; let summeKosten = 0;
let unbekannteKosten = 0;
const nachAgent = new Map(); const nachAgent = new Map();
const nachModell = new Map(); const nachModell = new Map();
for (const l of laeufe) { for (const l of laeufe) {
summeTokens += l.totalTokens || 0; const tokens = gesamtTokens(l);
summeTokens += tokens;
summePrompt += l.promptTokens || 0; summePrompt += l.promptTokens || 0;
summeCompletion += l.completionTokens || 0; summeCompletion += l.completionTokens || 0;
summeKosten += Number(l.cost || 0); summeKosten += Number(l.costUsd || 0);
if (!l.costIsKnown) unbekannteKosten++;
const agentKey = l.agentId || "Unbekannt"; const agentKey = l.agentId || "Unbekannt";
nachAgent.set(agentKey, (nachAgent.get(agentKey) || 0) + (l.totalTokens || 0)); nachAgent.set(agentKey, (nachAgent.get(agentKey) || 0) + tokens);
const modellKey = l.model || "Unbekannt"; const modellKey = l.model || "Unbekannt";
nachModell.set(modellKey, (nachModell.get(modellKey) || 0) + (l.totalTokens || 0)); nachModell.set(modellKey, (nachModell.get(modellKey) || 0) + tokens);
} }
// 1. Kacheln (Statistiken) // 1. Kacheln (Statistiken)
const kachelGitter = erstelleElement("div", { klasse: "kachel-gitter" }); const kachelGitter = erstelleElement("div", { klasse: "kachel-gitter" });
kachelGitter.append( kachelGitter.append(
erstelleStatKachel("Gesamtkosten (100 Läufe)", formatiereKosten(summeKosten)), erstelleStatKachel(`Gesamtkosten (${laeufe.length} Läufe)`, formatiereKosten(summeKosten)),
erstelleStatKachel("Tokens gesamt", formatiereTokens(summeTokens)), erstelleStatKachel("Tokens gesamt", formatiereTokens(summeTokens)),
erstelleStatKachel("Eingabe-Tokens (Prompt)", formatiereTokens(summePrompt)), erstelleStatKachel("Eingabe-Tokens (Prompt)", formatiereTokens(summePrompt)),
erstelleStatKachel("Ausgabe-Tokens (Completion)", formatiereTokens(summeCompletion)) erstelleStatKachel("Ausgabe-Tokens (Completion)", formatiereTokens(summeCompletion))
); );
const hinweise = [];
if (unbekannteKosten > 0) {
hinweise.push(
erstelleElement("p", {
klasse: "gedaempft text-klein",
text: `${unbekannteKosten} Lauf/Läufe ohne bekannten Preis — deren Kosten sind in der Summe nicht enthalten.`
})
);
}
// 2. Balkendiagramme (rein CSS/DOM) // 2. Balkendiagramme (rein CSS/DOM)
const diagrammGitter = erstelleElement("div", { klasse: "diagramm-gitter" }); const diagrammGitter = erstelleElement("div", { klasse: "diagramm-gitter" });
@@ -99,6 +120,7 @@ async function ladeVerbrauch() {
erstelleElement("th", { text: "Zeitpunkt" }), erstelleElement("th", { text: "Zeitpunkt" }),
erstelleElement("th", { text: "Agent" }), erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Modell" }), erstelleElement("th", { text: "Modell" }),
erstelleElement("th", { text: "Status" }),
erstelleElement("th", { text: "Prompt", stil: "text-align: right;" }), erstelleElement("th", { text: "Prompt", stil: "text-align: right;" }),
erstelleElement("th", { text: "Completion", stil: "text-align: right;" }), erstelleElement("th", { text: "Completion", stil: "text-align: right;" }),
erstelleElement("th", { text: "Gesamt", stil: "text-align: right;" }), erstelleElement("th", { text: "Gesamt", stil: "text-align: right;" }),
@@ -109,15 +131,20 @@ async function ladeVerbrauch() {
const tbody = erstelleElement("tbody"); const tbody = erstelleElement("tbody");
for (const lauf of laeufe) { for (const lauf of laeufe) {
const kosten = lauf.costIsKnown
? formatiereKosten(lauf.costUsd)
: `${formatiereKosten(lauf.costUsd)} (Preis unbekannt)`;
const tr = erstelleElement("tr"); const tr = erstelleElement("tr");
tr.append( tr.append(
erstelleElement("td", { text: formatiereDatumZeit(lauf.timestamp) }), erstelleElement("td", { text: formatiereDatumZeit(lauf.occurredAt) }),
erstelleElement("td", {}, erstelleElement("strong", { text: lauf.agentId })), erstelleElement("td", {}, erstelleElement("strong", { text: lauf.agentId })),
erstelleElement("td", { klasse: "gedaempft text-klein", text: lauf.model }), erstelleElement("td", { klasse: "gedaempft text-klein", text: lauf.model }),
erstelleElement("td", { klasse: "text-klein", text: lauf.status || "—" }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.promptTokens) }), erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.promptTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.completionTokens) }), erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.completionTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.totalTokens) }), erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(gesamtTokens(lauf)) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereKosten(lauf.cost) }) erstelleElement("td", { stil: "text-align: right;", text: kosten })
); );
tbody.append(tr); tbody.append(tr);
} }
@@ -125,7 +152,7 @@ async function ladeVerbrauch() {
tabelle.append(thead, tbody); tabelle.append(thead, tbody);
tabelleKarte.append(tabelle); tabelleKarte.append(tabelle);
container.append(kachelGitter, diagrammGitter, tabelleKarte); container.append(kachelGitter, ...hinweise, diagrammGitter, tabelleKarte);
} catch (err) { } catch (err) {
container.replaceChildren( container.replaceChildren(
erstelleKarte("Verbrauchsdaten nicht verfügbar", [ erstelleKarte("Verbrauchsdaten nicht verfügbar", [
+5 -2
View File
@@ -378,7 +378,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre // die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre
// das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es // das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es
// die Zustandsprüfung in #555. // die Zustandsprüfung in #555.
await File.AppendAllTextAsync(path, content, new UTF8Encoding(false), ct); // #575: Neu entstehende Dateien gehoeren nur dem Besitzer (0600).
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
await writer.WriteAsync(content.AsMemory(), ct);
return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}"); return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
} }
finally finally
@@ -599,7 +602,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// Datenpunkt speichern // Datenpunkt speichern
var json = JsonSerializer.Serialize(entry, StockJsonOpts); var json = JsonSerializer.Serialize(entry, StockJsonOpts);
await File.WriteAllTextAsync(filePath, json, new UTF8Encoding(false), ct); await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(filePath, json, new UTF8Encoding(false), ct);
// _index.json aktualisieren (thread-safe via per-file SemaphoreSlim, append-only) // _index.json aktualisieren (thread-safe via per-file SemaphoreSlim, append-only)
var indexPath = Path.Combine(stockDir, "_index.json"); var indexPath = Path.Combine(stockDir, "_index.json");
+77 -4
View File
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Net.Http;
using System.Net.Sockets; using System.Net.Sockets;
namespace ClawdDotNet.Tools.WebFetch; namespace ClawdDotNet.Tools.WebFetch;
@@ -14,6 +15,14 @@ namespace ClawdDotNet.Tools.WebFetch;
/// ///
/// Deshalb: Weiterleitungen werden nicht mehr automatisch verfolgt, sondern einzeln /// Deshalb: Weiterleitungen werden nicht mehr automatisch verfolgt, sondern einzeln
/// aufgelöst und JEDER Zwischenschritt erneut geprüft. /// aufgelöst und JEDER Zwischenschritt erneut geprüft.
///
/// <para><b>Und die Adresse wird dort geprüft, wo verbunden wird (#580).</b> Die Prüfung der
/// URL sieht nur den Namen. <c>localtest.me</c> steht als Name nirgends auf einer Sperrliste
/// und löst doch auf <c>127.0.0.1</c> auf — im Prüfdurchgang hat WebFetch genau so eine
/// Verbindung auf Loopback geöffnet. <see cref="ConnectAsync"/> löst den Namen selbst auf,
/// prüft jede Zieladresse und verbindet nur mit einer geprüften. Damit greift auch
/// DNS-Rebinding nicht mehr: Eine zweite Auflösung zwischen Prüfung und Verbindung gibt es
/// nicht.</para>
/// </summary> /// </summary>
public static class UrlGuard public static class UrlGuard
{ {
@@ -114,9 +123,12 @@ public static class UrlGuard
return true; return true;
} }
if (!IPAddress.TryParse(value, out var ip)) return IPAddress.TryParse(value, out var ip) && IsPrivateOrLocal(ip);
return false; }
/// <summary>Dieselbe Sperre für eine bereits aufgelöste Adresse.</summary>
public static bool IsPrivateOrLocal(IPAddress ip)
{
if (IPAddress.IsLoopback(ip)) if (IPAddress.IsLoopback(ip))
return true; return true;
@@ -138,7 +150,7 @@ public static class UrlGuard
if (ip.AddressFamily == AddressFamily.InterNetworkV6) if (ip.AddressFamily == AddressFamily.InterNetworkV6)
{ {
if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal) if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.Equals(IPAddress.IPv6None))
return true; return true;
// Unique Local Addresses fc00::/7 // Unique Local Addresses fc00::/7
@@ -148,9 +160,70 @@ public static class UrlGuard
// IPv4-gemappte Adressen erneut als IPv4 prüfen // IPv4-gemappte Adressen erneut als IPv4 prüfen
if (ip.IsIPv4MappedToIPv6) if (ip.IsIPv4MappedToIPv6)
return IsPrivateOrLocal(ip.MapToIPv4().ToString()); return IsPrivateOrLocal(ip.MapToIPv4());
} }
return false; return false;
} }
/// <summary>
/// Verbindungsaufbau für den <see cref="SocketsHttpHandler"/> von WebFetch (#580): erst
/// auflösen und prüfen, dann mit genau den geprüften Adressen verbinden.
/// </summary>
public static async ValueTask<Stream> ConnectAsync(
SocketsHttpConnectionContext context, CancellationToken ct)
{
var endpoint = context.DnsEndPoint;
var addresses = await ResolveAllowedAsync(
endpoint.Host, (host, token) => Dns.GetHostAddressesAsync(host, token), ct);
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(addresses, endpoint.Port, ct);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
/// <summary>
/// Löst einen Namen auf und gibt die Adressen nur zurück, wenn <b>keine</b> davon im
/// lokalen oder privaten Netz liegt.
///
/// <para>Alle und nicht „die erste erlaubte": Ein Name mit einer öffentlichen und einer
/// internen Adresse ist genau das Werkzeug, mit dem man eine Prüfung überlistet, die nur
/// eine Stichprobe nimmt.</para>
/// </summary>
/// <param name="resolve">Die Namensauflösung — austauschbar, damit sich die Sperre ohne
/// Netz prüfen lässt.</param>
public static async Task<IPAddress[]> ResolveAllowedAsync(
string host,
Func<string, CancellationToken, Task<IPAddress[]>> resolve,
CancellationToken ct)
{
var value = host.Trim().Trim('[', ']');
var addresses = IPAddress.TryParse(value, out var literal)
? [literal]
: await resolve(value, ct);
if (addresses.Length == 0)
throw new HttpRequestException($"Adresse '{host}' ließ sich nicht auflösen.");
foreach (var address in addresses)
{
if (IsPrivateOrLocal(address))
{
throw new HttpRequestException(
$"Adresse '{host}' löst auf {address} im lokalen oder privaten Netz auf und ist gesperrt.");
}
}
return addresses;
}
} }
+10 -1
View File
@@ -295,7 +295,16 @@ public sealed class WebFetchTool : IAgentTool
{ {
// Weiterleitungen bewusst NICHT automatisch verfolgen — sie werden einzeln // Weiterleitungen bewusst NICHT automatisch verfolgen — sie werden einzeln
// aufgelöst und geprüft. // aufgelöst und geprüft.
var handler = new HttpClientHandler { AllowAutoRedirect = false }; //
// #580: Die Zieladresse wird beim Verbinden geprüft, nicht nur der Name in der URL.
// Ohne Proxy, weil über einen Proxy der Proxy auflöst und verbindet — die Prüfung
// sähe dann nur dessen Adresse, nie das eigentliche Ziel.
var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseProxy = false,
ConnectCallback = UrlGuard.ConnectAsync
};
var client = new HttpClient(handler, disposeHandler: true); var client = new HttpClient(handler, disposeHandler: true);
var timeout = GetConfigInt(config, "timeoutSeconds", 15); var timeout = GetConfigInt(config, "timeoutSeconds", 15);
client.Timeout = TimeSpan.FromSeconds(timeout); client.Timeout = TimeSpan.FromSeconds(timeout);
+11 -2
View File
@@ -137,7 +137,15 @@ public sealed record TaskDto(
IReadOnlyList<string> BlockedBy, IReadOnlyList<string> BlockedBy,
string Body, string Body,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt); DateTime UpdatedAt,
TaskWhenDto? When = null);
/// <summary>
/// Der Termin einer Aufgabe (<c>cron</c>, <c>every</c>, <c>at</c>) samt Zeitzone. Ohne ihn
/// war in der Oberflaeche eine stuendliche Aufgabe von einer einmaligen nicht zu
/// unterscheiden (#591).
/// </summary>
public sealed record TaskWhenDto(string Kind, string Value, string TimeZone);
/// <summary>Ein eingefrorener Aufruf, der auf Freigabe wartet.</summary> /// <summary>Ein eingefrorener Aufruf, der auf Freigabe wartet.</summary>
public sealed record StagedCallDto( public sealed record StagedCallDto(
@@ -148,7 +156,8 @@ public sealed record StagedCallDto(
string? Action, string? Action,
string Proposal, string Proposal,
string Status, string Status,
string ArgumentsJson); string ArgumentsJson,
DateTime CreatedAt);
/// <summary>Warum ein Vorschlag abgelehnt wurde.</summary> /// <summary>Warum ein Vorschlag abgelehnt wurde.</summary>
public sealed record RejectDto(string Reason); public sealed record RejectDto(string Reason);
+6 -2
View File
@@ -119,7 +119,10 @@ internal static class Mapping
task.BlockedBy, task.BlockedBy,
task.Body, task.Body,
task.CreatedAt, task.CreatedAt,
task.UpdatedAt); task.UpdatedAt,
task.When is { } when
? new TaskWhenDto(when.Kind.ToString().ToLowerInvariant(), when.Value, when.TimeZone)
: null);
/// <summary> /// <summary>
/// Ein wartender Aufruf samt seiner eingefrorenen Argumente. /// Ein wartender Aufruf samt seiner eingefrorenen Argumente.
@@ -138,7 +141,8 @@ internal static class Mapping
call.Action, call.Action,
call.Proposal, call.Proposal,
call.Status.ToString(), call.Status.ToString(),
call.ArgumentsJson); call.ArgumentsJson,
call.CreatedAt);
public static UsageDto ToDto(this RunUsage usage) public static UsageDto ToDto(this RunUsage usage)
=> new( => new(
@@ -0,0 +1,100 @@
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Web.Contracts;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Baut aus den Chatverläufen der Agenten die Liste der Nachrichten zwischen Agenten (A2A).
///
/// <para><b>Absender und Empfänger heißen hier, was sie sind.</b> Eine Anfrage steht im
/// Verlauf des Empfängers; ihr Absender kommt aus dem Anzeigetext. Die Antwort steht im
/// selben Verlauf — Absender ist also der Antwortende, Empfänger der, der zuletzt gefragt
/// hat. Vorher trug die Antwort den Antwortenden als <c>target</c> und gar keinen
/// Absender, und der Filter der Weboberfläche fand sie beim Fragenden nicht (#536).</para>
///
/// <para><b>Was hier bewusst noch nicht gelöst ist:</b> Der Absender einer Anfrage wird
/// weiterhin aus dem Anzeigetext gelesen. Strukturiert am Eintrag kommt er erst mit
/// #207/#208; bis dahin ist das Lesen wenigstens so gebaut, dass ein fehlendes
/// Klammerpaar nicht mehr wirft.</para>
/// </summary>
internal static class AgentCommunicationBuilder
{
/// <summary>So beginnt eine Nachricht, die ein anderer Agent geschickt hat (AgentEngine).</summary>
internal const string Praefix = "[Nachricht von Agent \"";
public static List<AgentCommunicationDto> Build(
IEnumerable<(string AgentId, string DisplayName, IEnumerable<ChatEntry> History)> agents)
{
var list = new List<AgentCommunicationDto>();
foreach (var (agentId, displayName, history) in agents)
{
string? fragendeId = null;
string? fragenderName = null;
foreach (var entry in history)
{
if (!string.Equals(entry.Source, ChatSource.AgentComm, StringComparison.OrdinalIgnoreCase))
continue;
if (entry.Role == "user")
{
var (id, name, inhalt) = LeseAbsender(entry.Content);
fragendeId = id;
fragenderName = name;
list.Add(new AgentCommunicationDto(
TargetAgentId: agentId,
TargetAgentName: displayName,
SourceAgentId: id,
SourceAgentName: name,
Role: entry.Role,
Content: inhalt,
Timestamp: entry.Timestamp));
}
else if (entry.Role == "assistant")
{
list.Add(new AgentCommunicationDto(
TargetAgentId: fragendeId ?? "",
TargetAgentName: fragenderName ?? fragendeId ?? "",
SourceAgentId: agentId,
SourceAgentName: displayName,
Role: entry.Role,
Content: entry.Content,
Timestamp: entry.Timestamp));
}
}
}
return list.OrderBy(c => c.Timestamp).ToList();
}
/// <summary>
/// Liest aus <c>[Nachricht von Agent "Name" (id)]\n\nText</c> Kennung, Name und Text.
/// Passt die Form nicht, kommt der Inhalt unverändert und ohne Absender zurück.
/// </summary>
internal static (string? Id, string? Name, string Inhalt) LeseAbsender(string content)
{
if (!content.StartsWith(Praefix, StringComparison.Ordinal))
return (null, null, content);
string? name = null;
string? id = null;
var endQuote = content.IndexOf('"', Praefix.Length);
if (endQuote > Praefix.Length)
{
name = content[Praefix.Length..endQuote];
var openParen = content.IndexOf('(', endQuote);
var closeParen = openParen < 0 ? -1 : content.IndexOf(')', openParen);
if (openParen > 0 && closeParen > openParen)
id = content[(openParen + 1)..closeParen];
}
var doubleNl = content.IndexOf("\n\n", StringComparison.Ordinal);
var inhalt = doubleNl > 0 ? content[(doubleNl + 2)..] : content;
return (id, name, inhalt);
}
}
+6 -43
View File
@@ -107,50 +107,13 @@ internal static class ApiEndpoints
if (host.Engine is not { } engine) if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel)."); return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
var allAgents = host.AgentAdmin.All; var list = AgentCommunicationBuilder.Build(
var list = new List<AgentCommunicationDto>(); host.AgentAdmin.All.Select(agent => (
agent.AgentId,
agent.DisplayName,
(IEnumerable<Core.Engine.ChatEntry>)engine.GetChatHistory(agent.AgentId))));
foreach (var agent in allAgents) return Results.Ok(list);
{
var history = engine.GetChatHistory(agent.AgentId);
foreach (var entry in history.Where(e => string.Equals(e.Source, Core.Engine.ChatSource.AgentComm, StringComparison.OrdinalIgnoreCase)))
{
string? senderId = null;
string? senderName = null;
var content = entry.Content;
if (entry.Role == "user" && content.StartsWith("[Nachricht von Agent \""))
{
var endQuote = content.IndexOf('"', 22);
if (endQuote > 22)
{
senderName = content[22..endQuote];
var openParen = content.IndexOf('(', endQuote);
var closeParen = content.IndexOf(')', openParen);
if (openParen > 0 && closeParen > openParen)
{
senderId = content[(openParen + 1)..closeParen];
}
}
var doubleNl = content.IndexOf("\n\n", StringComparison.Ordinal);
if (doubleNl > 0)
{
content = content[(doubleNl + 2)..];
}
}
list.Add(new AgentCommunicationDto(
TargetAgentId: agent.AgentId,
TargetAgentName: agent.DisplayName,
SourceAgentId: senderId,
SourceAgentName: senderName,
Role: entry.Role,
Content: content,
Timestamp: entry.Timestamp));
}
}
return Results.Ok(list.OrderBy(c => c.Timestamp).ToList());
}); });
agents.MapGet("/{agentId}", (AppHost host, string agentId) => agents.MapGet("/{agentId}", (AppHost host, string agentId) =>
@@ -252,6 +252,14 @@ internal static class EventEndpoints
context.Response.Headers["X-Accel-Buffering"] = "no"; context.Response.Headers["X-Accel-Buffering"] = "no";
} }
private static string Format(string type, object payload) /// <summary>
=> $"event: {type}\ndata: {JsonSerializer.Serialize(payload)}\n\n"; /// Ein Ereignis in SSE-Form.
///
/// <para>Mit den Web-Vorgaben (camelCase) wie jede REST-Antwort (#591). Ohne sie kamen
/// Protokollzeilen als <c>Module/Level/Text</c> an, die Ansicht las <c>module/level/text</c>
/// — und zeigte von jeder Zeile nur den Instanznamen. Den anonymen Objekten der übrigen
/// Ereignisse sah man das nicht an, weil ihre Namen ohnehin klein geschrieben sind.</para>
/// </summary>
internal static string Format(string type, object payload)
=> $"event: {type}\ndata: {JsonSerializer.Serialize(payload, JsonSerializerOptions.Web)}\n\n";
} }
@@ -0,0 +1,147 @@
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// Regressionstests fuer die Befunde aus dem Testbetrieb auf der Linux-VM (#572).
/// </summary>
public sealed class TestbetriebRegressionTests
{
// ═══════════════════════════════════════════════════════════
// #578 — Abgebrochene Laeufe tragen ihre bezahlten Token
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task RunAsync_an_der_Schrittgrenze_meldet_die_bis_dahin_bezahlten_Token()
{
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
var agent = fixture.AddAgent("agent-grenze", "TestTool");
agent.LoopGuard.MaxSteps = 3;
// Jede Antwort ist ein Werkzeugaufruf mit 100 Prompt- und 20 Completion-Token.
fixture.Client.AlwaysRespondsWithToolCall("TestTool");
var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default);
result.Status.ShouldBe(AgentRunStatus.LoopLimitExceeded);
fixture.Client.CallCount.ShouldBe(3);
result.PromptTokens.ShouldBe(300, "drei bezahlte Aufrufe - vorher stand hier 0");
result.CompletionTokens.ShouldBe(60);
}
[Fact]
public async Task ChatAsync_an_der_Schrittgrenze_meldet_die_bis_dahin_bezahlten_Token()
{
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
var agent = fixture.AddAgent("agent-chat-grenze", "TestTool");
agent.LoopGuard.MaxSteps = 2;
fixture.Client.AlwaysRespondsWithToolCall("TestTool");
var result = await fixture.Engine.ChatAsync(agent, "Arbeite", "test-instance", default);
result.Status.ShouldBe(AgentRunStatus.LoopLimitExceeded);
result.PromptTokens.ShouldBe(200);
result.CompletionTokens.ShouldBe(40);
}
[Fact]
public async Task RunAsync_mit_Fehler_des_Anbieters_meldet_die_bis_dahin_bezahlten_Token()
{
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
var agent = fixture.AddAgent("agent-fehler", "TestTool");
fixture.Client
.RespondsWithToolCall("TestTool")
.Throws(new HttpRequestException("502 vom Anbieter"));
var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default);
result.Status.ShouldBe(AgentRunStatus.Failed);
result.PromptTokens.ShouldBe(100);
}
// ═══════════════════════════════════════════════════════════
// #581 — Unvollstaendiges Argument-JSON
// ═══════════════════════════════════════════════════════════
[Theory]
[InlineData("{\"action\": \"write\", \"workspace\": \"personal\", \"path\": \"gross.txt\"", false)]
[InlineData("{\"action\": \"send_message\", \"targetAgentId\": \"netzpruefer\"", false)]
[InlineData("{\"action\": \"list\"}", true)]
public void IstVollstaendigesJson_erkennt_abgeschnittene_Argumente(string arguments, bool erwartet)
=> AgentEngine.IstVollstaendigesJson(arguments).ShouldBe(erwartet);
[Fact]
public async Task Abgeschnittene_Argumente_erreichen_das_Werkzeug_nicht_und_das_Modell_erfaehrt_warum()
{
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
var agent = fixture.AddAgent("agent-abgeschnitten", "TestTool");
// Genau die Form aus dem Pruefstand: Das JSON bricht vor dem grossen Wert ab.
fixture.Client
.RespondsWithToolCall("TestTool", "{\"action\": \"write\", \"path\": \"gross.txt\"")
.RespondsWithText("verstanden");
await fixture.Engine.RunAsync(agent, "Schreibe viel", "test-instance", default);
var antwortAnsModell = fixture.Client.ReceivedRequests[1].Messages.Last();
antwortAnsModell.Role.ShouldBe("tool");
antwortAnsModell.Content.ShouldNotBeNull();
antwortAnsModell.Content.ShouldContain("kein vollstaendiges JSON");
antwortAnsModell.Content.ShouldContain("mehrere kleinere Aufrufe");
antwortAnsModell.Content.ShouldNotContain("Expected depth");
}
// ═══════════════════════════════════════════════════════════
// #589 — Zeit und Termin in der Auftragsnachricht
// ═══════════════════════════════════════════════════════════
[Fact]
public void Zeitangabe_nennt_Ortszeit_der_Aufgabe_UTC_und_Termin()
{
var task = new TaskItem
{
Id = "t-1",
Title = "Wetter",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" }
};
var text = EngineTaskDispatcher.Zeitangabe(task, new DateTimeOffset(2026, 9, 14, 13, 5, 0, TimeSpan.Zero));
text.ShouldContain("14.09.2026 15:05 Uhr");
text.ShouldContain("Europe/Berlin");
text.ShouldContain("2026-09-14T13:05Z");
text.ShouldContain("cron \"5 * * * *\"");
}
[Fact]
public void Auftragsnachricht_enthaelt_die_Zeitangabe()
{
var fixture = new EngineFixture();
var dispatcher = new EngineTaskDispatcher(
fixture.Engine, () => fixture.Agents, "test-instance", fixture.Registry, fixture.StateStore,
NullLoggerFactory.Instance, new FesteZeit(new DateTimeOffset(2026, 1, 5, 7, 15, 0, TimeSpan.Zero)));
var nachricht = dispatcher.BuildMessage(new TaskItem
{
Id = "t-2",
Title = "Morgenbericht",
Body = "Schreibe den Bericht.",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "15 7 * * *", TimeZone = "Europe/Berlin" }
});
nachricht.ShouldStartWith("[Aufgabe t-2] Morgenbericht");
nachricht.ShouldContain("Aktuelle Zeit: 05.01.2026 08:15 Uhr (Europe/Berlin, UTC+01:00)");
nachricht.ShouldContain("Schreibe den Bericht.");
}
private sealed class FesteZeit(DateTimeOffset jetzt) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => jetzt;
}
}
@@ -0,0 +1,74 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// #575: Auf der Test-VM (umask 0002) ging InstanceSettings.json beim ersten Speichern von
/// 0600 auf 0664, Protokolle entstanden mit 0664. Unter Windows nicht aussagekraeftig.
/// </summary>
public sealed class OwnerOnlyFileTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "clawd-owner-" + Guid.NewGuid().ToString("N")[..8]);
public OwnerOnlyFileTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* Aufraeumen ist best effort */ }
}
private const UnixFileMode NurBesitzer = UnixFileMode.UserRead | UnixFileMode.UserWrite;
[LinuxFact]
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
public void AtomicFile_ersetzt_eine_offene_Datei_durch_eine_nur_fuer_den_Besitzer()
{
var pfad = Path.Combine(_dir, "InstanceSettings.json");
File.WriteAllText(pfad, "{}");
File.SetUnixFileMode(pfad, NurBesitzer | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
AtomicFile.WriteAllText(pfad, "{\"a\":1}");
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
}
[LinuxFact]
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
public void AtomicFile_legt_neue_Dateien_nur_fuer_den_Besitzer_an()
{
var pfad = Path.Combine(_dir, "neu.json");
AtomicFile.WriteAllText(pfad, "{}");
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
}
[LinuxFact]
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
public void OpenAppend_legt_neue_Dateien_nur_fuer_den_Besitzer_an()
{
var pfad = Path.Combine(_dir, "protokoll.log");
using (var stream = OwnerOnlyFile.OpenAppend(pfad))
stream.WriteByte((byte)'x');
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
}
[Fact]
public void OpenAppend_haengt_an_statt_zu_ueberschreiben()
{
var pfad = Path.Combine(_dir, "anhang.txt");
foreach (var teil in new[] { "eins", "zwei" })
{
using var stream = OwnerOnlyFile.OpenAppend(pfad);
using var writer = new StreamWriter(stream);
writer.Write(teil);
}
File.ReadAllText(pfad).ShouldBe("einszwei");
}
}
@@ -0,0 +1,67 @@
using System.Net;
using System.Net.Http;
using ClawdDotNet.Tools.WebFetch;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.WebFetch;
/// <summary>
/// #580: Die Pruefung der URL sieht nur den Namen. localtest.me steht auf keiner Sperrliste
/// und loest auf 127.0.0.1 auf - im Pruefdurchgang oeffnete WebFetch damit eine Verbindung
/// auf Loopback. Geprueft wird deshalb die aufgeloeste Adresse, ohne echtes Netz.
/// </summary>
public sealed class UrlGuardAufloesungTests
{
private static Func<string, CancellationToken, Task<IPAddress[]>> Aufloesung(params string[] adressen)
=> (_, _) => Task.FromResult(adressen.Select(IPAddress.Parse).ToArray());
[Theory]
[InlineData("127.0.0.1")]
[InlineData("192.168.40.48")]
[InlineData("169.254.169.254")]
[InlineData("::1")]
[InlineData("::ffff:10.0.0.5")]
public async Task Ein_Name_der_ins_lokale_Netz_aufloest_wird_gesperrt(string adresse)
{
var ex = await Should.ThrowAsync<HttpRequestException>(
() => UrlGuard.ResolveAllowedAsync("localtest.me", Aufloesung(adresse), default));
ex.Message.ShouldContain("localtest.me");
ex.Message.ShouldContain("gesperrt");
}
[Fact]
public async Task Ein_Name_mit_oeffentlicher_und_interner_Adresse_wird_gesperrt()
{
// Genau das Muster, mit dem man eine Stichprobenpruefung ueberlistet.
await Should.ThrowAsync<HttpRequestException>(
() => UrlGuard.ResolveAllowedAsync("gemischt.example", Aufloesung("93.184.215.14", "127.0.0.1"), default));
}
[Fact]
public async Task Ein_Name_mit_oeffentlichen_Adressen_wird_zugelassen()
{
var adressen = await UrlGuard.ResolveAllowedAsync(
"example.com", Aufloesung("93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"), default);
adressen.Length.ShouldBe(2);
}
[Fact]
public async Task Ein_IP_Literal_wird_ohne_Aufloesung_geprueft()
{
var aufgerufen = false;
await Should.ThrowAsync<HttpRequestException>(() => UrlGuard.ResolveAllowedAsync(
"[::1]",
(_, _) => { aufgerufen = true; return Task.FromResult(Array.Empty<IPAddress>()); },
default));
aufgerufen.ShouldBeFalse();
}
[Fact]
public async Task Ein_Name_ohne_Adresse_wird_nicht_verbunden()
=> await Should.ThrowAsync<HttpRequestException>(
() => UrlGuard.ResolveAllowedAsync("nirgends.example", Aufloesung(), default));
}
@@ -0,0 +1,132 @@
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Web.Contracts;
using ClawdDotNet.Web.Endpoints;
using Shouldly;
namespace ClawdDotNet.Web.Tests;
/// <summary>
/// Befunde aus der Sichtung der Weboberflaeche im Testbetrieb (#572): #591 (Feldnamen) und
/// #536 (Richtung der A2A-Antworten).
/// </summary>
public sealed class TestbetriebWebTests
{
// ─── #591 Ereignisstrom und Uebertragungstypen ───
[Fact]
public void Protokollzeilen_im_Ereignisstrom_kommen_in_camelCase()
{
var ereignis = EventEndpoints.Format("log", new LogLineDto("Startup", "INF", "ClawdDotNet startet"));
ereignis.ShouldStartWith("event: log\n");
ereignis.ShouldContain("\"module\":\"Startup\"");
ereignis.ShouldContain("\"level\":\"INF\"");
ereignis.ShouldContain("\"text\":\"ClawdDotNet startet\"");
ereignis.ShouldNotContain("\"Text\"", Case.Sensitive);
}
[Fact]
public void Freigabe_traegt_ihren_Zeitpunkt()
{
var angelegt = new DateTime(2026, 9, 14, 10, 18, 53, DateTimeKind.Utc);
var dto = new StagedCall
{
Id = 1, AgentId = "hausmeister", Tool = "FileRW", Action = "delete",
ArgumentsJson = "{\"path\":\"betrieb/probe-loeschung.txt\"}", CreatedAt = angelegt
}.ToDto();
dto.CreatedAt.ShouldBe(angelegt);
dto.Tool.ShouldBe("FileRW");
dto.Action.ShouldBe("delete");
}
[Fact]
public void Aufgabe_traegt_ihren_Termin()
{
var dto = new TaskItem
{
Id = "t-a66932", Title = "Wetter Berlin stuendlich", Assignee = "@new:wetterdienst",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" }
}.ToDto();
dto.When.ShouldNotBeNull();
dto.When.Kind.ShouldBe("cron");
dto.When.Value.ShouldBe("5 * * * *");
dto.When.TimeZone.ShouldBe("Europe/Berlin");
dto.Assignee.ShouldBe("@new:wetterdienst");
}
[Fact]
public void Einmalige_Aufgabe_hat_keinen_Termin()
=> new TaskItem { Id = "t-1", Title = "Einrichtung" }.ToDto().When.ShouldBeNull();
// ─── #536 Absender und Empfaenger ───
private static ChatEntry Anfrage(string vonName, string vonId, string text, int minute)
=> new("user", $"[Nachricht von Agent \"{vonName}\" ({vonId})]\n\n{text}",
new DateTime(2026, 9, 14, 12, minute, 0), ChatSource.AgentComm);
private static ChatEntry Antwort(string text, int minute)
=> new("assistant", text, new DateTime(2026, 9, 14, 12, minute, 30), ChatSource.AgentComm);
[Fact]
public void Antwort_geht_vom_Antwortenden_an_den_Fragenden()
{
var liste = AgentCommunicationBuilder.Build(
[
("redaktion", "Redaktion", (IEnumerable<ChatEntry>)
[
Anfrage("Hausmeister", "hausmeister", "Testnachricht", 48),
Antwort("Empfang bestaetigt", 48)
])
]);
liste.Count.ShouldBe(2);
var anfrage = liste[0];
anfrage.SourceAgentId.ShouldBe("hausmeister");
anfrage.TargetAgentId.ShouldBe("redaktion");
anfrage.Content.ShouldBe("Testnachricht");
var antwort = liste[1];
antwort.SourceAgentId.ShouldBe("redaktion");
antwort.TargetAgentId.ShouldBe("hausmeister", "vorher stand hier der Antwortende selbst");
antwort.TargetAgentName.ShouldBe("Hausmeister");
}
[Fact]
public void Nur_Agentenkommunikation_wird_aufgenommen()
{
var liste = AgentCommunicationBuilder.Build(
[
("hausmeister", "Hausmeister", (IEnumerable<ChatEntry>)
[
new ChatEntry("user", "Hey, wie gehts?", DateTime.Now, ChatSource.WebUi),
new ChatEntry("assistant", "Moin!", DateTime.Now, ChatSource.WebUi)
])
]);
liste.ShouldBeEmpty();
}
[Theory]
[InlineData("[Nachricht von Agent \"Hausmeister\" ohne Klammer]\n\nText")]
[InlineData("[Nachricht von Agent \"Bo\"b\" (bob)]\n\nText")]
[InlineData("[Nachricht von Agent \"")]
public void Absender_lesen_wirft_bei_unerwarteter_Form_nicht(string inhalt)
=> Should.NotThrow(() => AgentCommunicationBuilder.LeseAbsender(inhalt));
[Fact]
public void Absender_lesen_liefert_Kennung_Name_und_Text()
{
var (id, name, text) = AgentCommunicationBuilder.LeseAbsender(
"[Nachricht von Agent \"Hausmeister\" (hausmeister)]\n\nHallo Redaktion!");
id.ShouldBe("hausmeister");
name.ShouldBe("Hausmeister");
text.ShouldBe("Hallo Redaktion!");
}
}