Stream panel member responses live with a parallel SSE write-lock

Panel members now stream token-by-token and show tool uses in real time
(like the session agent) instead of delivering text only at completion.
The key constraint is byte-level safety: N members flushing SSE frames
concurrently on the shared HttpResponse would interleave and corrupt the
stream, so a SemaphoreSlim gate serializes the writes (not the model work).

- SseHelper.TryWriteSse takes an optional write-lock (default null, backward
  compatible)
- NonStreamingLoopCallbacks carries SseGate + MemberIndex; _text/_tool/_error
  events are tagged with the member index (review/finalizer stay at -1, no gate)
- CLI members forward TextDelta/ToolStart/ToolResult live instead of
  discarding everything but the final text
- Anthropic-API members stream per tool-round via the loop callbacks; OpenAI
  members fall back to collected text at panel_member_done
- Frontend demuxes by member index into per-bubble interleaved text/tool
  rendering

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-18 13:18:13 +02:00
1 parent 268f11fa6d
commit f21ed9ff9f
5 files changed
+183 -34

No files matched your search

+12 -3
View File
@@ -416,6 +416,15 @@ public partial class AnthropicService
public string SsePrefix { get; set; }
public string AgentName { get; set; }
/// <summary>Optionaler Write-Lock: serialisiert SSE-Writes, wenn mehrere Agenten
/// (Panel-Member) parallel auf DIESELBE HttpResponse streamen. Null = kein Lock.</summary>
public SemaphoreSlim SseGate { get; set; }
/// <summary>Member-Index für Panel-Events (wird in die SSE-Payloads gelegt,
/// damit das Frontend Token/Tool-Events der richtigen Member-Bubble zuordnet).
/// -1 = kein Panel-Member (Review/Finalizer ignorieren das Feld).</summary>
public int MemberIndex { get; set; } = -1;
/// <summary>Tag für DB-Records (z.B. "review", "finalizer").</summary>
public string Tag { get; set; }
@@ -522,7 +531,7 @@ public partial class AnthropicService
Content = errMsg, IsError = true, Tag = cb.Tag
});
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { error = errMsg }, cb.SseCt);
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { index = cb.MemberIndex, error = errMsg }, cb.SseCt, cb.SseGate);
break;
}
@@ -560,7 +569,7 @@ public partial class AnthropicService
_embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text);
}
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { text, agentName = cb.AgentName }, cb.SseCt);
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { index = cb.MemberIndex, text, agentName = cb.AgentName }, cb.SseCt, cb.SseGate);
}
else if (blockType == "tool_use")
{
@@ -619,7 +628,7 @@ public partial class AnthropicService
});
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt);
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { index = cb.MemberIndex, name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt, cb.SseGate);
}
}
+55 -16
View File
@@ -52,7 +52,12 @@ public partial class AnthropicService
{
if (panel == null || panel.Count == 0) return 0;
await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode = "fusion" }, sseCt);
// Write-Lock: alle Member streamen LIVE parallel auf DIESELBE HttpResponse →
// ohne Serialisierung würden sich ihre SSE-Frames auf Byte-Ebene vermischen.
// Serialisiert nur die µs-Byte-Flushes, nicht die parallele Modell-Arbeit.
using var sseGate = new SemaphoreSlim(1, 1);
await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode = "fusion" }, sseCt, sseGate);
// Concurrency begrenzen: bei CLI-Membern sind das parallele claude-Children.
using var sem = new SemaphoreSlim(Math.Min(3, panel.Count));
@@ -64,7 +69,7 @@ public partial class AnthropicService
tasks.Add(Task.Run(async () =>
{
await sem.WaitAsync(workCt);
try { return await RunPanelMember(response, member, workerMessages, index, chainId, sessionId, apiToken, workCt, sseCt); }
try { return await RunPanelMember(response, member, workerMessages, index, chainId, sessionId, apiToken, sseGate, workCt, sseCt); }
finally { sem.Release(); }
}, workCt));
}
@@ -78,7 +83,7 @@ public partial class AnthropicService
if (ordered.Count == 0)
{
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt);
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt, sseGate);
return 0;
}
@@ -103,7 +108,7 @@ public partial class AnthropicService
MergePanelIntoLastUserText(workerMessages, sb.ToString());
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = ordered.Count }, sseCt);
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = ordered.Count }, sseCt, sseGate);
return ordered.Count;
}
@@ -120,11 +125,12 @@ public partial class AnthropicService
string chainId,
string sessionId,
string apiToken,
SemaphoreSlim sseGate,
CancellationToken workCt,
CancellationToken sseCt)
{
var name = member.Agent?.Name ?? $"Panel-Agent {index + 1}";
await SseHelper.TryWriteSse(response, "panel_member_start", new { index, name, model = member.ModelId }, sseCt);
await SseHelper.TryWriteSse(response, "panel_member_start", new { index, name, model = member.ModelId }, sseCt, sseGate);
var memberMessages = new List<object>(workerMessages); // eigene Kopie pro Member
string text;
@@ -139,11 +145,13 @@ public partial class AnthropicService
else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable)
{
// Kein API-Key → CLI-Member, read-only (Plan-Modus: nur Read/Glob/Grep).
text = await RunPanelMemberViaCli(member, memberMessages, workCt);
// Streamt LIVE (Token + Tool-Uses) über panel_member_*-Events.
text = await RunPanelMemberViaCli(response, member, memberMessages, index, sseGate, workCt, sseCt);
}
else
{
// Anthropic-API-Member: read-only (PlanMode) + NICHT persistieren (Caller persistiert einmal).
// Live-Streaming über den Loop: pro Tool-Runde panel_member_text / panel_member_tool.
var memberUsage = new TokenUsage();
var (t, _) = await RunNonStreamingToolLoop(
member.ModelId, member.SystemPrompt,
@@ -155,7 +163,12 @@ public partial class AnthropicService
},
memberMessages, chainId, sessionId, apiToken, memberUsage,
0, 12, workCt,
new NonStreamingLoopCallbacks { Persist = false },
new NonStreamingLoopCallbacks
{
Persist = false,
SseResponse = response, SseCt = sseCt, SseGate = sseGate,
SsePrefix = "panel_member", MemberIndex = index, AgentName = name
},
member.Effort);
text = t;
}
@@ -178,17 +191,27 @@ public partial class AnthropicService
}
await SseHelper.TryWriteSse(response, "panel_member_done",
new { index, name, model = member.ModelId, text }, sseCt);
new { index, name, model = member.ModelId, text }, sseCt, sseGate);
return new PanelMemberResult { Index = index, Name = name, Model = member.ModelId, Text = text };
}
/// <summary>
/// Führt einen Panel-Member über die Claude CLI aus (read-only via Plan-Modus).
/// Sammelt nur den Text, persistiert nichts und sendet keine Text-Deltas
/// (parallele Member → Deltas würden interleaven; der volle Text kommt via panel_member_done).
/// Führt einen Panel-Member über die Claude CLI aus (read-only via Plan-Modus) und
/// streamt seine Arbeit LIVE: Text-Deltas (panel_member_text), Tool-Start
/// (panel_member_tool_use) und Tool-Result (panel_member_tool_end) – jeweils mit
/// Member-Index, damit das Frontend sie der richtigen Bubble zuordnet. Die SSE-Writes
/// laufen durch den geteilten <paramref name="sseGate"/> (parallele Member → sonst
/// vermischen sich die Frames). Persistiert nichts – der Caller speichert eine Proposal-Message.
/// </summary>
private async Task<string> RunPanelMemberViaCli(SubAgentContext member, List<object> memberMessages, CancellationToken workCt)
private async Task<string> RunPanelMemberViaCli(
HttpResponse response,
SubAgentContext member,
List<object> memberMessages,
int index,
SemaphoreSlim sseGate,
CancellationToken workCt,
CancellationToken sseCt)
{
if (_cliService == null || !_cliService.IsAvailable) return "";
@@ -199,13 +222,29 @@ public partial class AnthropicService
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
var fullText = new StringBuilder();
string currentToolName = null; // ToolResult trägt keinen Namen → vom ToolStart merken
await _cliService.ChatStreamingAsync(
prompt,
evt =>
async evt =>
{
if (evt.Type == CliEventType.TextDelta)
fullText.Append(evt.Text);
return Task.CompletedTask;
switch (evt.Type)
{
case CliEventType.TextDelta:
fullText.Append(evt.Text);
await SseHelper.TryWriteSse(response, "panel_member_text",
new { index, text = evt.Text }, sseCt, sseGate);
break;
case CliEventType.ToolStart:
currentToolName = evt.ToolName;
await SseHelper.TryWriteSse(response, "panel_member_tool_use",
new { index, name = evt.ToolName }, sseCt, sseGate);
break;
case CliEventType.ToolResult:
await SseHelper.TryWriteSse(response, "panel_member_tool_end",
new { index, name = currentToolName, result = evt.ToolResultContent, isError = evt.ToolResultIsError }, sseCt, sseGate);
currentToolName = null;
break;
}
},
modelId: member.ModelId,
systemPrompt: systemText,
+17 -4
View File
@@ -25,17 +25,30 @@ public static class SseHelper
/// <summary>
/// Versucht ein SSE-Event zu schreiben, ignoriert Fehler (Client disconnected).
/// Optionaler <paramref name="gate"/>: serialisiert die Byte-Writes, wenn mehrere
/// Producer (z.B. parallele Panel-Member) auf DIESELBE HttpResponse schreiben –
/// sonst vermischen sich die SSE-Frames auf Byte-Ebene.
/// </summary>
public static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct)
public static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct, SemaphoreSlim gate = null)
{
if (ct.IsCancellationRequested) return;
bool acquired = false;
try
{
if (!ct.IsCancellationRequested)
await WriteSseEvent(response, eventType, data, ct);
if (gate != null)
{
await gate.WaitAsync(ct);
acquired = true;
}
await WriteSseEvent(response, eventType, data, ct);
}
catch
{
// Client bereits disconnected – ignorieren
// Client bereits disconnected / abgebrochen – ignorieren
}
finally
{
if (acquired) gate.Release();
}
}
+18 -1
View File
@@ -128,7 +128,7 @@ function handleStreamEvent(eventType, data) {
finalizeReviewBubble('done', data, 'finalizer');
break;
// Multi-Modell-Panel (Fusion): mehrere Member parallel, non-streaming
// Multi-Modell-Panel (Fusion): mehrere Member streamen parallel LIVE
case 'panel_start':
startPanel(data);
break;
@@ -137,6 +137,23 @@ function handleStreamEvent(eventType, data) {
addPanelMemberBubble(data);
break;
case 'panel_member_text':
appendPanelMemberText(data);
break;
case 'panel_member_tool_use':
addPanelMemberToolUse(data);
break;
// tool_end = CLI (offene Box füllen); tool = Anthropic-Loop (kombiniert → fertige Box)
case 'panel_member_tool_end': case 'panel_member_tool':
finishPanelMemberTool(data);
break;
case 'panel_member_error':
appendPanelMemberText({ index: data.index, text: '\n\n_[Fehler: ' + (data.error || 'unbekannt') + ']_' });
break;
case 'panel_member_done':
finalizePanelMember(data);
break;
+81 -10
View File
@@ -206,9 +206,10 @@ function finalizeReviewBubble(type, data, agentType) {
scrollDown();
}
// ── Panel Bubbles (Fusion: mehrere Member parallel, non-streaming) ──
// Member liefern ihren Text gesammelt bei panel_member_done → pro Member eine eigene
// Bubble (keyed by index), kein Konflikt mit dem Single-_reviewBubble-State.
// ── Panel Bubbles (Fusion: mehrere Member streamen parallel LIVE) ──
// Pro Member eine eigene Bubble (keyed by index). Innerhalb der Bubble werden Text
// und Tool-Boxen interleaved gerendert (wie beim Session-Agent), sodass parallele
// Member sich NICHT miteinander vermischen – das Demuxing macht der index der Events.
let _panelBubbles = {};
@@ -222,21 +223,91 @@ function addPanelMemberBubble(data) {
bubble.className = 'msg review-bubble panel-bubble streamed';
var model = (typeof prettyModelName === 'function') ? prettyModelName(data.model) : data.model;
bubble.appendChild(_createSubAgentHeader('panel', data.name, { model: model }));
var content = document.createElement('div');
content.className = 'review-content markdown';
content.innerHTML = '<span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span> denkt nach…';
bubble.appendChild(content);
bubble._panelContent = content;
var spinner = document.createElement('div');
spinner.className = 'review-content markdown';
spinner.innerHTML = '<span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span> denkt nach…';
bubble.appendChild(spinner);
bubble._panelSpinner = spinner; // wird beim ersten Text/Tool entfernt
bubble._segText = null; // aktuell offenes Text-Segment (oder null)
bubble._segTextStr = ''; // dessen akkumulierter Text
bubble._tool = null; // aktuell offene Tool-Box (CLI: Spinner→Füllen)
bubble._fullText = ''; // gesamter gestreamter Text (für Finalize-Fallback)
_panelBubbles[data.index] = bubble;
chatEl().appendChild(bubble);
scrollDown();
}
function _panelClearSpinner(bubble) {
if (bubble._panelSpinner) { bubble._panelSpinner.remove(); bubble._panelSpinner = null; }
}
function appendPanelMemberText(data) {
var bubble = _panelBubbles[data.index];
if (!bubble || !data.text) return;
_panelClearSpinner(bubble);
// Neues Text-Segment beginnen, wenn keins offen ist (z.B. direkt nach einer Tool-Box)
if (!bubble._segText) {
bubble._segText = document.createElement('div');
bubble._segText.className = 'review-content markdown';
bubble._segTextStr = '';
bubble.appendChild(bubble._segText);
}
bubble._segTextStr += data.text;
bubble._fullText += data.text;
bubble._segText.innerHTML = renderMarkdown(bubble._segTextStr);
scrollDown();
}
function addPanelMemberToolUse(data) {
var bubble = _panelBubbles[data.index];
if (!bubble) return;
_panelClearSpinner(bubble);
bubble._segText = null; // Text-Segment schließen → Folge-Text kommt UNTER die Tool-Box
if (!isToolVisible(data.name)) { bubble._tool = null; return; }
var toolDiv = document.createElement('div');
toolDiv.className = 'tool-box review-tool-box streamed';
toolDiv.dataset.toolName = data.name;
var displayName = TOOL_DISPLAY_NAMES[data.name] || data.name;
toolDiv.innerHTML = '<span class="tool-name">' + esc(displayName) + '</span> <span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span>';
bubble.appendChild(toolDiv);
bubble._tool = toolDiv;
scrollDown();
}
function finishPanelMemberTool(data) {
var bubble = _panelBubbles[data.index];
if (!bubble) return;
_panelClearSpinner(bubble);
bubble._segText = null; // nach Tool-Result neuen Text-Block beginnen
if (bubble._tool) {
// CLI: offene Box (mit Spinner) füllen
renderToolBoxContent(bubble._tool, data.name, null, data.result, data.isError, null);
bubble._tool = null;
} else {
// Anthropic-Loop (kombiniertes panel_member_tool-Event): fertige Box direkt anlegen
if (!isToolVisible(data.name)) return;
var toolDiv = document.createElement('div');
toolDiv.className = 'tool-box review-tool-box streamed';
bubble.appendChild(toolDiv);
renderToolBoxContent(toolDiv, data.name, null, data.result, data.isError, null);
}
scrollDown();
}
function finalizePanelMember(data) {
var bubble = _panelBubbles[data.index];
if (!bubble) return;
var text = (data.text && data.text.trim()) ? data.text : '_(keine Antwort)_';
bubble._panelContent.innerHTML = renderMarkdown(text);
_panelClearSpinner(bubble);
// Wenn LIVE nichts gestreamt wurde (z.B. OpenAI-Member = one-shot): finalen Text rendern.
if (!bubble._fullText || !bubble._fullText.trim()) {
var text = (data.text && data.text.trim()) ? data.text : '_(keine Antwort)_';
if (!bubble._segText) {
bubble._segText = document.createElement('div');
bubble._segText.className = 'review-content markdown';
bubble.appendChild(bubble._segText);
}
bubble._segText.innerHTML = renderMarkdown(text);
}
delete _panelBubbles[data.index];
scrollDown();
}