fix: SSE-Stream-Stabilität und History-Trimming tool_use/tool_result-Schutz
- Kestrel MinResponseDataRate per-Request deaktivieren für SSE-Streams (Default 240 bytes/s + 5s Grace → client_disconnected bei langen Tools) - SSE-Heartbeat alle 15s während Stream (schützt gegen Browser/Netzwerk-Idle) - History-Trimming: RemoveRange schneidet nicht mehr in tool_use/tool_result-Paare - Reminder-Insertion: kein Einfügen zwischen tool_use und tool_result
This commit is contained in:
1 parent
e81983cab6
commit
dd99216913
3 files changed
+101
-2
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -124,6 +125,13 @@ public static class ChatEndpoint
|
|||||||
var bufferingFeature = res.HttpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpResponseBodyFeature>();
|
var bufferingFeature = res.HttpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpResponseBodyFeature>();
|
||||||
bufferingFeature?.DisableBuffering();
|
bufferingFeature?.DisableBuffering();
|
||||||
|
|
||||||
|
// Kestrel MinResponseDataRate deaktivieren für diesen SSE-Stream.
|
||||||
|
// Ohne dies killt Kestrel die Verbindung wenn bei langen Tool-Ausführungen
|
||||||
|
// keine Daten fließen (Default: 240 bytes/s, 5s Grace → "client_disconnected").
|
||||||
|
var minRateFeature = res.HttpContext.Features.Get<Microsoft.AspNetCore.Server.Kestrel.Core.Features.IHttpMinResponseDataRateFeature>();
|
||||||
|
if (minRateFeature != null)
|
||||||
|
minRateFeature.MinDataRate = null;
|
||||||
|
|
||||||
// ── Preparer-Agent Phase (vor dem Worker) ──
|
// ── Preparer-Agent Phase (vor dem Worker) ──
|
||||||
if (ctx.Preparer != null && !isChoiceResponse)
|
if (ctx.Preparer != null && !isChoiceResponse)
|
||||||
{
|
{
|
||||||
@@ -171,6 +179,23 @@ public static class ChatEndpoint
|
|||||||
var stopCts = streamingTracker.Start(sessionId, ctx.ChainId);
|
var stopCts = streamingTracker.Start(sessionId, ctx.ChainId);
|
||||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stopCts.Token);
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stopCts.Token);
|
||||||
|
|
||||||
|
// SSE-Heartbeat: Alle 15s ein Kommentar senden, damit die Verbindung
|
||||||
|
// bei langen Tool-Ausführungen nicht von Browser/Netzwerk getrennt wird.
|
||||||
|
var heartbeatTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!linkedCts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(15000, linkedCts.Token);
|
||||||
|
await res.WriteAsync(": heartbeat\n\n", linkedCts.Token);
|
||||||
|
await res.Body.FlushAsync(linkedCts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { }
|
||||||
|
}, linkedCts.Token);
|
||||||
|
|
||||||
try
|
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, cancellationToken: linkedCts.Token);
|
||||||
@@ -178,6 +203,7 @@ public static class ChatEndpoint
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
streamingTracker.Stop(sessionId);
|
streamingTracker.Stop(sessionId);
|
||||||
|
// Heartbeat-Task beenden (linkedCts wird disposed → Token cancelled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
|
var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
|
||||||
|
|||||||
+74
-2
@@ -445,8 +445,67 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
|
|||||||
}
|
}
|
||||||
|
|
||||||
// History auf maximal N Messages begrenzen (älteste entfernen)
|
// History auf maximal N Messages begrenzen (älteste entfernen)
|
||||||
|
// WICHTIG: Nicht mitten in einem tool_use/tool_result-Paar abschneiden!
|
||||||
if (history.Count > _maxHistory)
|
if (history.Count > _maxHistory)
|
||||||
history.RemoveRange(0, history.Count - _maxHistory);
|
{
|
||||||
|
int removeCount = history.Count - _maxHistory;
|
||||||
|
// Nach vorne korrigieren bis wir an einer sicheren Stelle sind:
|
||||||
|
// Nicht abschneiden wenn die erste verbleibende Message ein tool_result ist
|
||||||
|
// oder die letzte entfernte Message ein assistant mit tool_use ist
|
||||||
|
while (removeCount < history.Count - 1)
|
||||||
|
{
|
||||||
|
var firstRemaining = history[removeCount];
|
||||||
|
bool isToolResult = false;
|
||||||
|
if (firstRemaining is Dictionary<string, object> frDict &&
|
||||||
|
frDict.TryGetValue("role", out var frRole) && frRole.ToString() == "user" &&
|
||||||
|
frDict.TryGetValue("content", out var frContent) && frContent is List<object> frList)
|
||||||
|
{
|
||||||
|
isToolResult = frList.Any(item =>
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(item);
|
||||||
|
return json.Contains("\"tool_result\"");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToolResult)
|
||||||
|
{
|
||||||
|
// Die erste verbleibende Message ist ein tool_result → muss den vorherigen
|
||||||
|
// assistant[tool_use] auch behalten → ein Element weniger entfernen
|
||||||
|
removeCount--;
|
||||||
|
// Jetzt prüfen ob der neue Start auch sicher ist (könnte auch tool_result sein)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob die letzte entfernte Message ein assistant mit tool_use war
|
||||||
|
bool lastRemovedHasToolUse = false;
|
||||||
|
if (removeCount > 0)
|
||||||
|
{
|
||||||
|
var lastRemoved = history[removeCount - 1];
|
||||||
|
if (lastRemoved is Dictionary<string, object> lrDict &&
|
||||||
|
lrDict.TryGetValue("role", out var lrRole) && lrRole.ToString() == "assistant" &&
|
||||||
|
lrDict.TryGetValue("content", out var lrContent) && lrContent is List<object> lrList)
|
||||||
|
{
|
||||||
|
lastRemovedHasToolUse = lrList.Any(item =>
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(item);
|
||||||
|
return json.Contains("\"tool_use\"");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastRemovedHasToolUse)
|
||||||
|
{
|
||||||
|
// Letztes entferntes Element enthält tool_use → auch das tool_result entfernen
|
||||||
|
removeCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeCount > 0 && removeCount < history.Count)
|
||||||
|
history.RemoveRange(0, removeCount);
|
||||||
|
}
|
||||||
|
|
||||||
// Reminder einfügen: alle ~30 user/assistant-Paare ein Reminder-Paar einfügen
|
// Reminder einfügen: alle ~30 user/assistant-Paare ein Reminder-Paar einfügen
|
||||||
var reminder = SystemPromptBuilder.BuildReminder(promptBlocks);
|
var reminder = SystemPromptBuilder.BuildReminder(promptBlocks);
|
||||||
@@ -473,7 +532,20 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
|
|||||||
{
|
{
|
||||||
// Position nach dem aktuellen Element – aber nur einfügen wenn es eine
|
// Position nach dem aktuellen Element – aber nur einfügen wenn es eine
|
||||||
// assistant-Message ist (damit user/assistant-Alternierung nicht bricht)
|
// assistant-Message ist (damit user/assistant-Alternierung nicht bricht)
|
||||||
if (history[i] is Dictionary<string, object> d &&
|
// UND die nächste Message kein tool_result ist (sonst bricht tool_use/tool_result-Paar)
|
||||||
|
bool nextIsToolResult = false;
|
||||||
|
if (i + 1 < history.Count && history[i + 1] is Dictionary<string, object> nextDict &&
|
||||||
|
nextDict.TryGetValue("role", out var nextRole) && nextRole.ToString() == "user" &&
|
||||||
|
nextDict.TryGetValue("content", out var nextContent) && nextContent is List<object> nextList)
|
||||||
|
{
|
||||||
|
nextIsToolResult = nextList.Any(item =>
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(item);
|
||||||
|
return json.Contains("\"tool_result\"");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextIsToolResult && history[i] is Dictionary<string, object> d &&
|
||||||
d.TryGetValue("role", out var r) && r.ToString() == "assistant")
|
d.TryGetValue("role", out var r) && r.ToString() == "assistant")
|
||||||
{
|
{
|
||||||
insertPositions.Add(i + 1);
|
insertPositions.Add(i + 1);
|
||||||
|
|||||||
Reference in new issue
Block a user