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:
@@ -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.
|
||||||
|
|||||||
+17
-5
@@ -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,8 +91,12 @@ 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)")
|
||||||
|
|
||||||
with urllib.request.urlopen(f"{INSTALLER_BASE}/{entry['file']}", timeout=180) as r:
|
if agent_file is not None:
|
||||||
payload = r.read()
|
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:
|
||||||
|
payload = r.read()
|
||||||
|
|
||||||
actual = hashlib.sha256(payload).hexdigest()
|
actual = hashlib.sha256(payload).hexdigest()
|
||||||
if actual != entry["sha256"].lower():
|
if actual != entry["sha256"].lower():
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 & ~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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
|
||||||
erstellePlakette(m.targetAgentName || m.targetAgentId, "erfolg"),
|
absenderGruppe.append(
|
||||||
erstelleElement("span", { klasse: "a2a-pfeil", text: "➔ Antwort" })
|
erstellePlakette(quelleName, istAntwort ? "erfolg" : "neutral"),
|
||||||
);
|
erstelleElement("span", { klasse: "a2a-pfeil", text: istAntwort ? "➔ Antwort an" : "➔" }),
|
||||||
} else {
|
erstellePlakette(zielName, istAntwort ? "neutral" : "primaer")
|
||||||
// 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") {
|
||||||
alleNachrichten.push(neueNachricht);
|
const fragender = letzterFragender.get(daten.agentId);
|
||||||
|
neueNachricht = {
|
||||||
const gewaehlterAgent = agentSelect.value;
|
targetAgentId: fragender?.id || "",
|
||||||
if (
|
targetAgentName: fragender?.name || fragender?.id || "",
|
||||||
!gewaehlterAgent ||
|
sourceAgentId: daten.agentId,
|
||||||
neueNachricht.targetAgentId === gewaehlterAgent ||
|
sourceAgentName: agentName,
|
||||||
neueNachricht.sourceAgentId === gewaehlterAgent
|
role: daten.role,
|
||||||
) {
|
content: daten.content || "",
|
||||||
// Falls vorher "Keine Nachrichten" da stand:
|
timestamp: daten.timestamp || new Date().toISOString()
|
||||||
const leerKarte = verlaufContainer.querySelector(".karte");
|
};
|
||||||
if (leerKarte) verlaufContainer.replaceChildren();
|
} else {
|
||||||
|
return;
|
||||||
const el = erstelleNachrichtElement(neueNachricht);
|
|
||||||
verlaufContainer.append(el);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alleNachrichten.push(neueNachricht);
|
||||||
|
rendereNachrichten();
|
||||||
});
|
});
|
||||||
|
|
||||||
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", [
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user