Agent-Arbeit vom Client-Disconnect entkoppeln

Zwei separate CancellationTokens:
- workCt (nur Stop-Button): Anthropic API-Calls, Tool-Ausfuehrungen, DB-Writes
  Agent laeuft bei SSE-Disconnect weiter, Client pollt Ergebnisse
- sseCt (Stop-Button + Client-Disconnect): SSE-Writes an Browser
  Bei Disconnect werden Writes zu stillen No-Ops

Vorher: HttpContext.RequestAborted killte den gesamten Agent-Lauf
wenn die SSE-Verbindung abriss (Browser-Tab idle, Netzwerk-Hickup).
Jetzt arbeitet der Agent im Hintergrund weiter.

OperationCanceledException = 'stopped' (nur Stop-Button)
statt 'client_disconnected' (war falsch verknuepft)
This commit is contained in:
Meleki committed 2026-03-10 06:28:35 +01:00
1 parent dd99216913
commit e9b1e8ef20
2 files changed
+72 -58

No files matched your search

+15 -9
View File
@@ -175,9 +175,15 @@ public static class ChatEndpoint
// Bei Choice-Response: toolChoice auf "auto" setzen, damit Claude frei antworten kann
var effectiveToolChoice = isChoiceResponse ? "auto" : ctx.ToolChoice;
// CancellationToken: Verknüpft Stop-Button (über StreamingTracker) + Client-Disconnect
// Zwei separate CancellationTokens:
// - workCt: Nur Stop-Button → für Anthropic API-Calls, Tool-Ausführungen, DB-Writes
// Agent-Arbeit läuft bei Client-Disconnect weiter (Client pollt ja)
// - sseCt: Stop-Button + Client-Disconnect → für SSE-Writes an den Browser
// Bei Client-Disconnect werden SSE-Writes zu stillen No-Ops
var stopCts = streamingTracker.Start(sessionId, ctx.ChainId);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stopCts.Token);
var workCt = stopCts.Token;
using var sseLinkCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stopCts.Token);
var sseCt = sseLinkCts.Token;
// SSE-Heartbeat: Alle 15s ein Kommentar senden, damit die Verbindung
// bei langen Tool-Ausführungen nicht von Browser/Netzwerk getrennt wird.
@@ -185,25 +191,25 @@ public static class ChatEndpoint
{
try
{
while (!linkedCts.Token.IsCancellationRequested)
while (!sseCt.IsCancellationRequested)
{
await Task.Delay(15000, linkedCts.Token);
await res.WriteAsync(": heartbeat\n\n", linkedCts.Token);
await res.Body.FlushAsync(linkedCts.Token);
await Task.Delay(15000, sseCt);
await res.WriteAsync(": heartbeat\n\n", sseCt);
await res.Body.FlushAsync(sseCt);
}
}
catch (OperationCanceledException) { }
catch { }
}, linkedCts.Token);
}, sseCt);
try
{
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, cancellationToken: linkedCts.Token);
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, workCt: workCt, sseCt: sseCt);
}
finally
{
streamingTracker.Stop(sessionId);
// Heartbeat-Task beenden (linkedCts wird disposed → Token cancelled)
// Heartbeat-Task beenden (sseLinkCts wird disposed → Token cancelled)
}
}