B2 beheben: Chat-Laeufe pro Agent serialisieren
Der Konversationskontext _chatContexts[agentId] ist eine geteilte List<ChatMessage>. Nur der Lookup lief unter Lock, alle Add-Aufrufe im Schleifenkoerper waren ungeschuetzt. Da WebView, ToolJob-Wakeups und AgentComm denselben Agenten gleichzeitig ansprechen koennen, verschraenkten sich ihre Nachrichten zu einer ungueltigen Tool-Sequenz, die die API mit HTTP 400 ablehnt. ChatAsync laeuft jetzt hinter einem SemaphoreSlim(1,1) pro Agent; verschiedene Agenten bleiben unabhaengig. Die Timeout-Uhr startet erst nach dem Eintritt, damit Wartezeit in der Warteschlange den Lauf nicht aufzehrt. Zwei Folgeprobleme mit demselben Ursprung: - _runningChats hielt nur EINE CancellationTokenSource je Agent; der zweite Lauf ueberschrieb den ersten. AbortChat brach dadurch nur einen ab, der andere lief bis ins Run-Timeout. Jetzt eine Liste, die auch wartende Laeufe erfasst. - ExecuteToolCallAsync fing OperationCanceledException mit ab und gab sie als Tool-Ergebnis zurueck, wodurch der Abbruch erst einen Schritt spaeter griff. Cancellation wird nun durchgereicht. Ausserdem: send_message an den eigenen Agenten wird abgelehnt — es waere mit dem neuen Gate in einen Deadlock gelaufen. Neu: GetChatContext(agentId) als Momentaufnahme des Kontexts, fuer Diagnose und Kontextgroessen-Anzeige. Build-Fix: Das WinForms-Projekt globbt **/*.cs und kompilierte dadurch die Test-Quellen mit. tests\** wird jetzt wie src\** ausgeschlossen. Alle 49 Tests gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eae13771cf
commit
6bbe9f9a80
@@ -19,10 +19,15 @@
|
|||||||
<CopyOutputSymbolsToOutputDirectory Condition="'$(Configuration)' == 'Release'">false</CopyOutputSymbolsToOutputDirectory>
|
<CopyOutputSymbolsToOutputDirectory Condition="'$(Configuration)' == 'Release'">false</CopyOutputSymbolsToOutputDirectory>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Unterordner mit eigenen Projekten aus dem Glob des Hauptprojekts nehmen.
|
||||||
|
Ohne das kompiliert die WinForms-App die Test-Quellen mit. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Remove="src\**" />
|
<Compile Remove="src\**" />
|
||||||
<None Remove="src\**" />
|
<None Remove="src\**" />
|
||||||
<EmbeddedResource Remove="src\**" />
|
<EmbeddedResource Remove="src\**" />
|
||||||
|
<Compile Remove="tests\**" />
|
||||||
|
<None Remove="tests\**" />
|
||||||
|
<EmbeddedResource Remove="tests\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -178,6 +178,12 @@ sodass `AbortChat` nur den zuletzt gestarteten Lauf abbricht.
|
|||||||
**Fix:** Pro Agent ein `SemaphoreSlim(1,1)`, das den gesamten `ChatAsync`-Durchlauf serialisiert.
|
**Fix:** Pro Agent ein `SemaphoreSlim(1,1)`, das den gesamten `ChatAsync`-Durchlauf serialisiert.
|
||||||
Wartende Nachrichten in eine Queue statt parallel starten.
|
Wartende Nachrichten in eine Queue statt parallel starten.
|
||||||
|
|
||||||
|
Beim Umsetzen kamen zwei Folgeprobleme dazu, die denselben Ursprung haben:
|
||||||
|
- `ExecuteToolCallAsync` fing `OperationCanceledException` mit ab und gab sie als
|
||||||
|
Tool-Fehlerergebnis zurück. Der Abbruch griff dadurch erst einen Schritt später.
|
||||||
|
- `send_message` an den eigenen Agenten wäre mit dem neuen Gate in einen Deadlock gelaufen
|
||||||
|
(der laufende Chat hält es bereits) — wird jetzt abgelehnt.
|
||||||
|
|
||||||
### B3 — `maxTokens` vermischt Abrechnungs-Budget und Kontextgröße ⚠️
|
### B3 — `maxTokens` vermischt Abrechnungs-Budget und Kontextgröße ⚠️
|
||||||
[`LoopGuard.cs:22`](../src/ClawdDotNet.Core/Engine/LoopGuard.cs#L22), Defaults in
|
[`LoopGuard.cs:22`](../src/ClawdDotNet.Core/Engine/LoopGuard.cs#L22), Defaults in
|
||||||
[`AgentConfig.cs:105`](../src/ClawdDotNet.Core/Config/AgentConfig.cs#L105)
|
[`AgentConfig.cs:105`](../src/ClawdDotNet.Core/Config/AgentConfig.cs#L105)
|
||||||
@@ -521,7 +527,7 @@ Siehe K3.
|
|||||||
1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben
|
1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben
|
||||||
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
|
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
|
||||||
2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben
|
2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben
|
||||||
3. B2 Race Condition im Chat-Kontext
|
3. ~~B2 Race Condition im Chat-Kontext~~ ✅ behoben
|
||||||
4. S2 yt-dlp-Injection
|
4. S2 yt-dlp-Injection
|
||||||
5. S3 API-Key-Leak
|
5. S3 API-Key-Leak
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,23 @@ public sealed class AgentEngine : IAgentMessageRouter
|
|||||||
|
|
||||||
private readonly Dictionary<string, List<ChatEntry>> _chatHistories = new();
|
private readonly Dictionary<string, List<ChatEntry>> _chatHistories = new();
|
||||||
private readonly Dictionary<string, List<ChatMessage>> _chatContexts = new();
|
private readonly Dictionary<string, List<ChatMessage>> _chatContexts = new();
|
||||||
private readonly Dictionary<string, CancellationTokenSource> _runningChats = new();
|
|
||||||
private readonly Lock _lock = new();
|
private readonly Lock _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alle aktiven Chat-Läufe je Agent — laufende wie wartende. Mehrere Quellen können
|
||||||
|
/// denselben Agenten gleichzeitig ansprechen (WebView, ToolJob, AgentComm), deshalb
|
||||||
|
/// eine Liste: AbortChat muss jeden davon erreichen.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, List<CancellationTokenSource>> _runningChats = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialisiert ChatAsync pro Agent. Der Konversationskontext ist eine geteilte
|
||||||
|
/// Liste — liefen zwei Chats desselben Agenten gleichzeitig, verschränkten sich ihre
|
||||||
|
/// Nachrichten zu einer ungültigen Tool-Sequenz, die die API mit HTTP 400 ablehnt.
|
||||||
|
/// Verschiedene Agenten bleiben unabhängig voneinander.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, SemaphoreSlim> _agentGates = new();
|
||||||
|
|
||||||
private Func<IReadOnlyList<AgentConfig>>? _agentConfigProvider;
|
private Func<IReadOnlyList<AgentConfig>>? _agentConfigProvider;
|
||||||
private Func<string, string?>? _agentDirResolver;
|
private Func<string, string?>? _agentDirResolver;
|
||||||
private string _instanceId = "";
|
private string _instanceId = "";
|
||||||
@@ -227,18 +241,53 @@ public sealed class AgentEngine : IAgentMessageRouter
|
|||||||
string instanceId,
|
string instanceId,
|
||||||
CancellationToken externalCt,
|
CancellationToken externalCt,
|
||||||
string? source = null)
|
string? source = null)
|
||||||
|
{
|
||||||
|
// Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
|
||||||
|
// wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
|
||||||
|
using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
|
||||||
|
RegisterRun(agentConfig.AgentId, runCts);
|
||||||
|
|
||||||
|
var gate = GetAgentGate(agentConfig.AgentId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(runCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
UnregisterRun(agentConfig.AgentId, runCts);
|
||||||
|
return new AgentRunResult(
|
||||||
|
agentConfig.AgentId, AgentRunStatus.Cancelled, "[Chat abgebrochen]",
|
||||||
|
0, 0, TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UnregisterRun(agentConfig.AgentId, runCts);
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AgentRunResult> ChatCoreAsync(
|
||||||
|
AgentConfig agentConfig,
|
||||||
|
string userMessage,
|
||||||
|
string instanceId,
|
||||||
|
CancellationToken runCt,
|
||||||
|
string? source)
|
||||||
{
|
{
|
||||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
|
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
|
||||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
// Die Timeout-Uhr läuft erst ab hier — Wartezeit in der Warteschlange
|
||||||
|
// darf den Lauf nicht aufzehren.
|
||||||
using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
|
using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
|
||||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt, timeoutCts.Token);
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(runCt, timeoutCts.Token);
|
||||||
var ct = linkedCts.Token;
|
var ct = linkedCts.Token;
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
_runningChats[agentConfig.AgentId] = linkedCts;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tools = _toolRegistry.GetForAgent(agentConfig);
|
var tools = _toolRegistry.GetForAgent(agentConfig);
|
||||||
@@ -376,10 +425,46 @@ public sealed class AgentEngine : IAgentMessageRouter
|
|||||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
finally
|
}
|
||||||
|
|
||||||
|
// ─── Nebenläufigkeits-Helfer ───
|
||||||
|
|
||||||
|
private SemaphoreSlim GetAgentGate(string agentId)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
_runningChats.Remove(agentConfig.AgentId);
|
{
|
||||||
|
if (!_agentGates.TryGetValue(agentId, out var gate))
|
||||||
|
{
|
||||||
|
gate = new SemaphoreSlim(1, 1);
|
||||||
|
_agentGates[agentId] = gate;
|
||||||
|
}
|
||||||
|
return gate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterRun(string agentId, CancellationTokenSource cts)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_runningChats.TryGetValue(agentId, out var list))
|
||||||
|
{
|
||||||
|
list = new List<CancellationTokenSource>();
|
||||||
|
_runningChats[agentId] = list;
|
||||||
|
}
|
||||||
|
list.Add(cts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnregisterRun(string agentId, CancellationTokenSource cts)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_runningChats.TryGetValue(agentId, out var list))
|
||||||
|
return;
|
||||||
|
|
||||||
|
list.Remove(cts);
|
||||||
|
if (list.Count == 0)
|
||||||
|
_runningChats.Remove(agentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,12 +482,38 @@ public sealed class AgentEngine : IAgentMessageRouter
|
|||||||
return _runningChats.ContainsKey(agentId);
|
return _runningChats.ContainsKey(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AbortChat(string agentId)
|
/// <summary>
|
||||||
|
/// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten,
|
||||||
|
/// die beim nächsten Schritt tatsächlich an das Modell gehen.
|
||||||
|
/// Nützlich für Diagnose und Kontextgrößen-Anzeige.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ChatMessage> GetChatContext(string agentId)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
return _chatContexts.TryGetValue(agentId, out var ctx)
|
||||||
|
? ctx.ToList()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bricht ALLE Chat-Läufe des Agenten ab — laufende wie wartende.
|
||||||
|
/// </summary>
|
||||||
|
public void AbortChat(string agentId)
|
||||||
{
|
{
|
||||||
if (_runningChats.TryGetValue(agentId, out var cts))
|
List<CancellationTokenSource> toCancel;
|
||||||
cts.Cancel();
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_runningChats.TryGetValue(agentId, out var list))
|
||||||
|
return;
|
||||||
|
toCancel = list.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Außerhalb des Locks abbrechen: Cancel führt Continuations aus, die
|
||||||
|
// ihrerseits wieder auf _lock zugreifen können.
|
||||||
|
foreach (var cts in toCancel)
|
||||||
|
{
|
||||||
|
try { cts.Cancel(); }
|
||||||
|
catch (ObjectDisposedException) { /* Lauf war bereits fertig */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +625,12 @@ public sealed class AgentEngine : IAgentMessageRouter
|
|||||||
if (configs is null)
|
if (configs is null)
|
||||||
return new AgentMessageResult(false, null, "Agent config provider not set.");
|
return new AgentMessageResult(false, null, "Agent config provider not set.");
|
||||||
|
|
||||||
|
// Selbstadressierung würde am Agent-Gate hängen bleiben: Der laufende Chat
|
||||||
|
// hält es bereits und würde auf sich selbst warten.
|
||||||
|
if (fromAgentId == toAgentId)
|
||||||
|
return new AgentMessageResult(false, null,
|
||||||
|
"Ein Agent kann sich keine Nachricht an sich selbst schicken.");
|
||||||
|
|
||||||
var targetConfig = configs.FirstOrDefault(a => a.AgentId == toAgentId);
|
var targetConfig = configs.FirstOrDefault(a => a.AgentId == toAgentId);
|
||||||
if (targetConfig is null)
|
if (targetConfig is null)
|
||||||
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
|
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
|
||||||
@@ -678,6 +795,12 @@ public sealed class AgentEngine : IAgentMessageRouter
|
|||||||
logger.LogWarning("Tool access denied: {Message}", ex.Message);
|
logger.LogWarning("Tool access denied: {Message}", ex.Message);
|
||||||
return JsonSerializer.Serialize(new { error = ex.Message });
|
return JsonSerializer.Serialize(new { error = ex.Message });
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
|
||||||
|
// Schritt weiter und der Abbruch greift erst verzögert.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
|
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using ClawdDotNet.Core.Engine;
|
||||||
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regressionstests für Bug B2: _chatContexts[agentId] ist eine geteilte
|
||||||
|
/// List<ChatMessage>. Nur der Lookup läuft unter Lock — alle Add-Aufrufe im
|
||||||
|
/// Schleifenkörper sind ungeschützt.
|
||||||
|
///
|
||||||
|
/// Derselbe Agent kann gleichzeitig von mehreren Seiten angestoßen werden:
|
||||||
|
/// Telegram-/Mail-ToolJob, WebView-Nachricht des Benutzers, send_message eines
|
||||||
|
/// anderen Agenten. Ergebnis: korrupte Liste, verschränkte Tool-Sequenzen und
|
||||||
|
/// dadurch HTTP-400-Fehler.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AgentEngineConcurrencyTests
|
||||||
|
{
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// E1 — Zwei parallele Chats auf demselben Agenten
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[Repeat(15)]
|
||||||
|
public async Task Zwei_parallele_Chats_hinterlassen_einen_gueltigen_Kontext(int iteration)
|
||||||
|
{
|
||||||
|
_ = iteration;
|
||||||
|
|
||||||
|
var fixture = new EngineFixture()
|
||||||
|
.WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(15)));
|
||||||
|
var agent = fixture.AddAgent("agent-a", "TestTool");
|
||||||
|
|
||||||
|
// Jeder Lauf: ein Tool-Schritt, dann eine Textantwort — unabhängig davon,
|
||||||
|
// wie die beiden Konversationen ineinander verschränkt werden.
|
||||||
|
fixture.Client.RespondsContextually();
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
fixture.Engine.ChatAsync(agent, "Anfrage A", "test-instance", default),
|
||||||
|
fixture.Engine.ChatAsync(agent, "Anfrage B", "test-instance", default));
|
||||||
|
|
||||||
|
var context = fixture.Engine.GetChatContext(agent.AgentId);
|
||||||
|
ContextInvariants.AssertValid(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// E2 — Stresstest
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Viele_parallele_Chats_korrumpieren_den_Kontext_nicht()
|
||||||
|
{
|
||||||
|
var fixture = new EngineFixture()
|
||||||
|
.WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(2)));
|
||||||
|
var agent = fixture.AddAgent("agent-stress", "TestTool");
|
||||||
|
|
||||||
|
fixture.Client.RespondsContextually();
|
||||||
|
|
||||||
|
var tasks = Enumerable.Range(0, 30)
|
||||||
|
.Select(i => fixture.Engine.ChatAsync(agent, $"Anfrage {i}", "test-instance", default));
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
results.ShouldAllBe(r => r.Status == AgentRunStatus.Completed);
|
||||||
|
|
||||||
|
var context = fixture.Engine.GetChatContext(agent.AgentId);
|
||||||
|
ContextInvariants.AssertValid(context);
|
||||||
|
|
||||||
|
// Jede der 30 Anfragen muss genau einmal im Kontext stehen — nichts verloren,
|
||||||
|
// nichts doppelt durch verlorene Schreibzugriffe auf die Liste.
|
||||||
|
var userMessages = context.Count(m => m.Role == "user");
|
||||||
|
userMessages.ShouldBe(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// E3 — AbortChat
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbortChat_bricht_alle_laufenden_Chats_des_Agenten_ab()
|
||||||
|
{
|
||||||
|
var fixture = new EngineFixture()
|
||||||
|
.WithTool(FakeTool.Slow(TimeSpan.FromSeconds(10)));
|
||||||
|
var agent = fixture.AddAgent("agent-abort", "TestTool");
|
||||||
|
|
||||||
|
fixture.Client.AlwaysRespondsWithToolCall();
|
||||||
|
|
||||||
|
var first = fixture.Engine.ChatAsync(agent, "Erste", "test-instance", default);
|
||||||
|
var second = fixture.Engine.ChatAsync(agent, "Zweite", "test-instance", default);
|
||||||
|
|
||||||
|
// Warten, bis beide tatsächlich angelaufen sind.
|
||||||
|
await WaitUntilAsync(() => fixture.Engine.IsRunning(agent.AgentId));
|
||||||
|
await Task.Delay(50);
|
||||||
|
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
fixture.Engine.AbortChat(agent.AgentId);
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(first, second);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
results.ShouldAllBe(r => r.Status == AgentRunStatus.Cancelled);
|
||||||
|
|
||||||
|
// Entscheidend: _runningChats[agentId] hielt bisher nur EINE CancellationTokenSource —
|
||||||
|
// der zweite Lauf überschrieb den ersten. AbortChat brach dann nur einen ab, der
|
||||||
|
// andere lief bis ins Run-Timeout. Ohne diese Zeitprüfung wäre der Test grün,
|
||||||
|
// obwohl der Abbruch gar nicht gegriffen hat.
|
||||||
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5),
|
||||||
|
"AbortChat muss beide Läufe sofort beenden, nicht erst über das Timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// E4 — Gegenprobe: verschiedene Agenten dürfen parallel laufen
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Verschiedene_Agenten_laufen_wirklich_parallel()
|
||||||
|
{
|
||||||
|
// Wichtige Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
|
||||||
|
// gegeneinander sperren.
|
||||||
|
var toolDelay = TimeSpan.FromMilliseconds(200);
|
||||||
|
var fixture = new EngineFixture().WithTool(FakeTool.Slow(toolDelay));
|
||||||
|
|
||||||
|
var a = fixture.AddAgent("agent-1", "TestTool");
|
||||||
|
var b = fixture.AddAgent("agent-2", "TestTool");
|
||||||
|
var c = fixture.AddAgent("agent-3", "TestTool");
|
||||||
|
|
||||||
|
fixture.Client.AlwaysRespondsWithText("Antwort");
|
||||||
|
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
await Task.WhenAll(
|
||||||
|
fixture.Engine.ChatAsync(a, "x", "test-instance", default),
|
||||||
|
fixture.Engine.ChatAsync(b, "x", "test-instance", default),
|
||||||
|
fixture.Engine.ChatAsync(c, "x", "test-instance", default));
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
// Seriell wären es mindestens 3 × 200 ms. Parallel deutlich weniger.
|
||||||
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(450),
|
||||||
|
"Agenten dürfen sich nicht gegenseitig blockieren");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 2_000)
|
||||||
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
while (!condition() && sw.ElapsedMilliseconds < timeoutMs)
|
||||||
|
await Task.Delay(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Engine;
|
||||||
|
using ClawdDotNet.Core.Security;
|
||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baut eine einsatzbereite AgentEngine mit Fake-Client, Fake-Tools und
|
||||||
|
/// In-Memory-State zusammen.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class EngineFixture
|
||||||
|
{
|
||||||
|
public FakeChatClient Client { get; } = new();
|
||||||
|
public ToolRegistry Registry { get; } = new();
|
||||||
|
public InMemoryStateStore StateStore { get; } = new();
|
||||||
|
public AgentEngine Engine { get; }
|
||||||
|
|
||||||
|
private readonly List<AgentConfig> _agents = new();
|
||||||
|
|
||||||
|
public EngineFixture()
|
||||||
|
{
|
||||||
|
Engine = new AgentEngine(
|
||||||
|
Client,
|
||||||
|
Registry,
|
||||||
|
new PermissionGate(),
|
||||||
|
StateStore,
|
||||||
|
TestLogging.Factory);
|
||||||
|
|
||||||
|
// Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests.
|
||||||
|
Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EngineFixture WithTool(IAgentTool tool)
|
||||||
|
{
|
||||||
|
Registry.Register(tool);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentConfig AddAgent(string agentId, params string[] toolNames)
|
||||||
|
{
|
||||||
|
var config = new AgentConfig
|
||||||
|
{
|
||||||
|
AgentId = agentId,
|
||||||
|
DisplayName = agentId,
|
||||||
|
Model = "test/model",
|
||||||
|
SystemPrompt = $"Du bist {agentId}.",
|
||||||
|
LoopGuard = new LoopGuardConfig
|
||||||
|
{
|
||||||
|
MaxSteps = 50,
|
||||||
|
MaxCumulativeTokens = 10_000_000,
|
||||||
|
TimeoutSeconds = 60,
|
||||||
|
MaxContextTokens = 10_000_000 // Compaction in Engine-Tests ausschalten
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var name in toolNames)
|
||||||
|
config.Tools[name] = new Dictionary<string, object?>();
|
||||||
|
|
||||||
|
_agents.Add(config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ internal sealed class FakeChatClient : IChatCompletionClient
|
|||||||
{
|
{
|
||||||
private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
|
private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
|
||||||
private Func<ChatRequest, ChatResponse>? _fallback;
|
private Func<ChatRequest, ChatResponse>? _fallback;
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
|
||||||
/// <summary>Alle empfangenen Requests, als tiefe Kopien.</summary>
|
/// <summary>Alle empfangenen Requests, als tiefe Kopien.</summary>
|
||||||
public List<ChatRequest> ReceivedRequests { get; } = new();
|
public List<ChatRequest> ReceivedRequests { get; } = new();
|
||||||
@@ -35,20 +36,26 @@ internal sealed class FakeChatClient : IChatCompletionClient
|
|||||||
|
|
||||||
public FakeChatClient RespondsWithToolCalls(params (string Tool, string Args, string? Id)[] calls)
|
public FakeChatClient RespondsWithToolCalls(params (string Tool, string Args, string? Id)[] calls)
|
||||||
{
|
{
|
||||||
var toolCalls = calls.Select((c, i) => new ToolCall
|
_responses.Enqueue(_ => ToolCallResponse(calls));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatResponse ToolCallResponse((string Tool, string Args, string? Id)[] calls)
|
||||||
{
|
{
|
||||||
Id = c.Id ?? $"call_{Guid.NewGuid():N}"[..12],
|
// Ids müssen pro Antwort eindeutig sein, sonst kollidieren parallele Läufe.
|
||||||
|
var toolCalls = calls.Select(c => new ToolCall
|
||||||
|
{
|
||||||
|
Id = c.Id ?? "call_" + Guid.NewGuid().ToString("N")[..12],
|
||||||
Type = "function",
|
Type = "function",
|
||||||
Function = new ToolCallFunction { Name = c.Tool, Arguments = c.Args }
|
Function = new ToolCallFunction { Name = c.Tool, Arguments = c.Args }
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
_responses.Enqueue(_ => new ChatResponse
|
return new ChatResponse
|
||||||
{
|
{
|
||||||
Id = "resp_" + ReceivedRequests.Count,
|
Id = "resp",
|
||||||
Choices = [new Choice { Index = 0, Message = new ChatMessage { Role = "assistant", ToolCalls = toolCalls } }],
|
Choices = [new Choice { Index = 0, Message = new ChatMessage { Role = "assistant", ToolCalls = toolCalls } }],
|
||||||
Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 }
|
Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 }
|
||||||
});
|
};
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Antwortet mit einer bestimmten Prompt-Token-Zahl — für Compaction-Schwellen.</summary>
|
/// <summary>Antwortet mit einer bestimmten Prompt-Token-Zahl — für Compaction-Schwellen.</summary>
|
||||||
@@ -76,22 +83,47 @@ internal sealed class FakeChatClient : IChatCompletionClient
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public FakeChatClient AlwaysRespondsWithToolCall(string toolName = "TestTool")
|
||||||
|
{
|
||||||
|
_fallback = _ => ToolCallResponse([(toolName, "{}", null)]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Antwortet passend zum jeweiligen Gesprächsstand: nach einer Tool-Antwort
|
||||||
|
/// kommt Text, sonst ein Tool-Aufruf. Damit verhält sich der Fake auch dann
|
||||||
|
/// sinnvoll, wenn mehrere Konversationen ineinander verschränkt laufen.
|
||||||
|
/// </summary>
|
||||||
|
public FakeChatClient RespondsContextually(string toolName = "TestTool")
|
||||||
|
{
|
||||||
|
_fallback = req => req.Messages.LastOrDefault()?.Role == "tool"
|
||||||
|
? TextResponse("Fertig")
|
||||||
|
: ToolCallResponse([(toolName, "{}", null)]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── IChatCompletionClient ───
|
// ─── IChatCompletionClient ───
|
||||||
|
|
||||||
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
|
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
ReceivedRequests.Add(DeepClone(request));
|
|
||||||
|
|
||||||
if (_responses.Count > 0)
|
// Erst kopieren, dann auswerten: Die Engine mutiert die Original-Liste
|
||||||
return Task.FromResult(_responses.Dequeue()(request));
|
// weiter, und bei Nebenläufigkeitstests soll der Fake daran nicht scheitern.
|
||||||
|
var snapshot = DeepClone(request);
|
||||||
|
lock (_gate)
|
||||||
|
ReceivedRequests.Add(snapshot);
|
||||||
|
|
||||||
if (_fallback is not null)
|
Func<ChatRequest, ChatResponse>? responder;
|
||||||
return Task.FromResult(_fallback(request));
|
lock (_gate)
|
||||||
|
responder = _responses.Count > 0 ? _responses.Dequeue() : _fallback;
|
||||||
|
|
||||||
|
if (responder is null)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"FakeChatClient: unerwarteter Aufruf Nr. {ReceivedRequests.Count} — " +
|
$"FakeChatClient: unerwarteter Aufruf Nr. {ReceivedRequests.Count} — " +
|
||||||
"keine Antwort mehr programmiert.");
|
"keine Antwort mehr programmiert.");
|
||||||
|
|
||||||
|
return Task.FromResult(responder(snapshot));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helfer ───
|
// ─── Helfer ───
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Konfigurierbares Tool für Engine-Tests: liefert feste Ergebnisse, kann verzögern
|
||||||
|
/// (um Nebenläufigkeit zu provozieren) oder werfen.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeTool : IAgentTool
|
||||||
|
{
|
||||||
|
private readonly Func<JsonElement, AgentToolContext, CancellationToken, Task<ToolResult>> _handler;
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
public string Description => $"Test-Tool {Name}";
|
||||||
|
|
||||||
|
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||||
|
{ "type": "object", "properties": { "action": { "type": "string" } } }
|
||||||
|
""").RootElement.Clone();
|
||||||
|
|
||||||
|
/// <summary>Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id.</summary>
|
||||||
|
public ConcurrentBag<string> Invocations { get; } = new();
|
||||||
|
|
||||||
|
private FakeTool(string name, Func<JsonElement, AgentToolContext, CancellationToken, Task<ToolResult>> handler)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FakeTool Returning(string result, string name = "TestTool")
|
||||||
|
=> new(name, (_, _, _) => Task.FromResult(ToolResult.Ok(result)));
|
||||||
|
|
||||||
|
/// <summary>Verzögert die Ausführung — vergrößert das Zeitfenster für Races.</summary>
|
||||||
|
public static FakeTool Slow(TimeSpan delay, string result = "ok", string name = "TestTool")
|
||||||
|
=> new(name, async (_, _, ct) =>
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
return ToolResult.Ok(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
public static FakeTool Throwing(Exception ex, string name = "TestTool")
|
||||||
|
=> new(name, (_, _, _) => throw ex);
|
||||||
|
|
||||||
|
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Invocations.Add(context.AgentId);
|
||||||
|
return _handler(input, context, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class InMemoryStateStore : ClawdDotNet.Core.State.IStateStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, string> _values = new();
|
||||||
|
|
||||||
|
public Task<string?> GetAsync(string key, CancellationToken ct)
|
||||||
|
=> Task.FromResult(_values.GetValueOrDefault(key));
|
||||||
|
|
||||||
|
public Task SetAsync(string key, string value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_values[key] = value;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(string key, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_values.TryRemove(key, out _);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Führt einen Theory-Test mehrfach aus.
|
||||||
|
///
|
||||||
|
/// Race Conditions treten sporadisch auf — ein einmaliger Durchlauf sagt wenig.
|
||||||
|
/// Der Testparameter ist der Durchlauf-Index und wird üblicherweise ignoriert.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||||
|
public sealed class RepeatAttribute(int count) : DataAttribute
|
||||||
|
{
|
||||||
|
public override IEnumerable<object[]> GetData(MethodInfo testMethod)
|
||||||
|
=> Enumerable.Range(1, count).Select(i => new object[] { i });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user