Add workflow-status telemetry view and make panel modes on-demand

Workflow-status: a new WorkflowProgressTracker singleton taps the
suppressed dynamic-workflow subagent events (those carrying
parent_tool_use_id) into per-session telemetry without altering the
existing guard; the snapshot is serialized into the new chains.workflow_info
column on FinalizeChain (ChainId-matched so a panel member of the same
session cannot clear it) and exposed via GET /api/sessions/{id}/workflow-status
(live -> persisted -> none). The header menu gains a 'Workflow-Status' entry
with a modal that live-polls every 2s while running and self-stops on close.

Panel on-demand: Fusion/MoA is now a transient per-request multiModelMode
flag (like plan/ultracode) instead of a persisted session column. Session
settings keep only the panel members + moa_layers; the mode <select> was
removed and the composer dropdown drives the mode per request.

Docs: ARCHITECTURE.md, ARCHITECTURE_QUICK.md and README.md updated for both
features plus the already-shipped z.ai/GLM provider, GLM 5.2+ effort
passthrough, web-search-prime MCP and the ultracode toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-20 17:27:46 +02:00
1 parent 5b1802efee
commit b6e546f618
22 files changed
+591 -99

No files matched your search

+2
View File
@@ -307,6 +307,8 @@ public partial class AnthropicService
mcpConfigPath: mcpConfigPath,
hasUnityMcp: !string.IsNullOrEmpty(unityMcpUrl),
effort: effort,
sessionId: sessionId,
chainId: chainId,
cancellationToken: segCts.Token
);
}
+15 -2
View File
@@ -44,11 +44,12 @@ public partial class AnthropicService
private readonly VectorSearchService _vectorSearch;
private readonly EmbeddingBackgroundService _embeddingBg;
private readonly SteeringQueue _steeringQueue;
private readonly WorkflowProgressTracker _workflowTracker;
private ChatService _chatService;
private TelegramService _telegramService;
private ClaudeCliService _cliService;
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, SteeringQueue steeringQueue = null)
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, SteeringQueue steeringQueue = null, WorkflowProgressTracker workflowTracker = null)
{
_httpFactory = httpFactory;
_toolService = toolService;
@@ -58,6 +59,7 @@ public partial class AnthropicService
_vectorSearch = vectorSearch;
_embeddingBg = embeddingBg;
_steeringQueue = steeringQueue;
_workflowTracker = workflowTracker;
}
public void SetCliService(ClaudeCliService cliService)
@@ -320,7 +322,18 @@ public partial class AnthropicService
}
private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw, string streamErrorInfo = null)
=> SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo);
{
// Workflow-Telemetrie (ultracode-Subagenten) für DIESE Chain persistieren und den
// Live-Tracker freigeben. Der ChainId-Abgleich verhindert, dass ein anderer Finalize-Pfad
// derselben Session (z.B. ein Panel-Member) den Workflow-Eintrag fälschlich abräumt.
if (_workflowTracker != null && !string.IsNullOrEmpty(chain.SessionId)
&& _workflowTracker.ChainIdFor(chain.SessionId) == chain.ChainId)
{
chain.WorkflowInfo = _workflowTracker.GetJson(chain.SessionId);
_workflowTracker.End(chain.SessionId);
}
return SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo);
}
/// <summary>
/// Versieht das letzte Element einer Block-Liste (Tools, System-Prompt-Blöcke, etc.) mit
+9 -5
View File
@@ -84,7 +84,7 @@ Nur deine finale Text-Antwort wird weiterverwendet. Formuliere sie vollständig
/// <summary>
/// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc.
/// </summary>
public async Task<ChatContext> PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false, bool hasRemoteConnection = false, string remoteOs = null)
public async Task<ChatContext> PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false, string multiModelMode = null, bool hasRemoteConnection = false, string remoteOs = null)
{
var session = await _db.GetSession(sessionId);
if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId))
@@ -167,10 +167,14 @@ Nur deine finale Text-Antwort wird weiterverwendet. Formuliere sie vollständig
var finalizerContext = await BuildSubAgentContext(
session.FinalizerAgentId, sessionId, workspace, session, user);
// Multi-Modell-Panel (Fusion): Panel-Agenten (Proposer) bauen, falls aktiviert.
// Jeder Panel-Agent ist ein vollwertiger SubAgentContext (Modell/Provider/Key/Prompt/Effort).
// Multi-Modell-Panel (Fusion/MoA): Panel-Agenten (Proposer) bauen, falls aktiviert.
// Der Modus ist on-demand (transient pro Request, wie Plan-/Ultracode-Modus) → er kommt
// aus dem Request-Flag, NICHT mehr aus der Session. Die Panel-Mitglieder bleiben persistent
// (session.PanelAgentIds), nur ob/wie das Panel läuft entscheidet die einzelne Anfrage.
// Nur "fusion"/"moa" sind gültig; alles andere (leer/"off"/Müll) → kein Panel.
var effectiveMultiModelMode = (multiModelMode == "fusion" || multiModelMode == "moa") ? multiModelMode : "off";
var panelContexts = new List<SubAgentContext>();
if (!string.IsNullOrEmpty(session.MultiModelMode) && session.MultiModelMode != "off"
if (effectiveMultiModelMode != "off"
&& !string.IsNullOrWhiteSpace(session.PanelAgentIds))
{
var panelIds = session.PanelAgentIds
@@ -251,7 +255,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
Preparer = preparerContext,
Review = reviewContext,
Finalizer = finalizerContext,
MultiModelMode = session.MultiModelMode,
MultiModelMode = effectiveMultiModelMode,
PanelAgents = panelContexts,
MoaLayers = session.MoaLayers,
PlanMode = planMode
+53 -3
View File
@@ -21,11 +21,13 @@ public class ClaudeCliService
private readonly string _cliPath;
private readonly bool _isAvailable;
private readonly ServerSettingsService _settings;
private readonly WorkflowProgressTracker _workflowTracker;
public ClaudeCliService(ILogger<ClaudeCliService> logger, ServerSettingsService settings)
public ClaudeCliService(ILogger<ClaudeCliService> logger, ServerSettingsService settings, WorkflowProgressTracker workflowTracker = null)
{
_logger = logger;
_settings = settings;
_workflowTracker = workflowTracker;
_cliPath = FindCliPath();
_isAvailable = !string.IsNullOrEmpty(_cliPath) && File.Exists(_cliPath);
@@ -90,6 +92,44 @@ public class ClaudeCliService
return major > 5 || (major == 5 && minor >= 2);
}
/// <summary>
/// Greift aus einem (im Haupt-Stream unterdrückten) Subagent-Turn die Telemetrie ab:
/// die vom Subagenten aufgerufenen Tool-Namen und seine Token-Nutzung. Rein additiv für den
/// Workflow-Status – wirft nie (Telemetrie darf den Stream-Parser nicht stören).
/// </summary>
private void TapSubagentEvent(string sessionId, string chainId, string parentToolUseId, JsonElement json)
{
try
{
if (!json.TryGetProperty("message", out var msg))
return;
var toolNames = new List<string>();
if (msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
{
foreach (var item in content.EnumerateArray())
{
if (item.TryGetProperty("type", out var it) && it.GetString() == "tool_use"
&& item.TryGetProperty("name", out var nm))
{
var n = nm.GetString();
if (!string.IsNullOrEmpty(n)) toolNames.Add(n);
}
}
}
long inTok = 0, outTok = 0;
if (msg.TryGetProperty("usage", out var usage))
{
if (usage.TryGetProperty("input_tokens", out var it2) && it2.ValueKind == JsonValueKind.Number) inTok = it2.GetInt64();
if (usage.TryGetProperty("output_tokens", out var ot2) && ot2.ValueKind == JsonValueKind.Number) outTok = ot2.GetInt64();
}
_workflowTracker.RecordSubagent(sessionId, chainId, parentToolUseId, toolNames, inTok, outTok);
}
catch { /* Telemetrie darf den Stream nie stören */ }
}
private ProcessStartInfo CreateCliStartInfo(List<string> args, bool redirectInput = false, string effort = null, string workingDirectory = null, string modelId = null)
{
var psi = new ProcessStartInfo
@@ -219,6 +259,8 @@ public class ClaudeCliService
string mcpConfigPath = null,
bool hasUnityMcp = false,
string effort = null,
string sessionId = null,
string chainId = null,
CancellationToken cancellationToken = default)
{
if (!_isAvailable)
@@ -354,7 +396,7 @@ public class ClaudeCliService
args.Add(mcpConfigPath);
}
return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken, effortToUse, workspace, modelId);
return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken, effortToUse, workspace, modelId, sessionId, chainId);
}
private async Task<CliChatResult> RunCliStreamingAsync(
@@ -364,7 +406,9 @@ public class ClaudeCliService
CancellationToken cancellationToken,
string effort = null,
string workspace = null,
string modelId = null)
string modelId = null,
string sessionId = null,
string chainId = null)
{
var result = new CliChatResult();
var fullOutput = new StringBuilder();
@@ -509,6 +553,12 @@ public class ClaudeCliService
&& parentToolUseId.ValueKind == JsonValueKind.String
&& !string.IsNullOrEmpty(parentToolUseId.GetString()))
{
// Subagent-Event: weiterhin aus dem Haupt-Stream AUSBLENDEN (continue) –
// hier wird nur die Telemetrie für den Workflow-Status ABGEGRIFFEN
// (Tool-Aufrufe + Subagent-Tokens), ohne den sichtbaren Verlauf zu verändern.
// Die konsolidierten Subagent-Turns kommen als type:"assistant" (mit usage).
if (_workflowTracker != null && !string.IsNullOrEmpty(sessionId) && type == "assistant")
TapSubagentEvent(sessionId, chainId, parentToolUseId.GetString(), json);
continue;
}
+1
View File
@@ -253,6 +253,7 @@ public partial class DatabaseService
iterations = @Iterations,
stop_reason = @StopReason,
stream_error_info = @StreamErrorInfo,
workflow_info = @WorkflowInfo,
duration_ms = @DurationMs
WHERE chain_id = @ChainId
", chain);
+1
View File
@@ -148,6 +148,7 @@ public partial class DatabaseService
}
catch { /* already migrated */ }
TryAddColumn(conn, "chains", "stream_error_info", "TEXT");
TryAddColumn(conn, "chains", "workflow_info", "TEXT");
TryAddColumn(conn, "agents", "use_soul", "INTEGER NOT NULL DEFAULT 1");
TryAddColumn(conn, "agents", "use_identity", "INTEGER NOT NULL DEFAULT 1");
TryAddColumn(conn, "agents", "use_user_info", "INTEGER NOT NULL DEFAULT 1");
+131
View File
@@ -0,0 +1,131 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Gott.Services;
/// <summary>
/// Sammelt Live-Telemetrie laufender dynamischer Workflows ("ultracode"): die Aktivität der
/// Subagenten, die die Claude CLI über das Workflow-/Task-Tool spawnt. Diese Subagent-Events
/// werden im Haupt-Stream bewusst unterdrückt (parent_tool_use_id-Guard in ClaudeCliService) –
/// hier werden sie abgegriffen, damit der Workflow-Fortschritt LIVE (während des Laufs) und
/// danach (persistiert in chains.workflow_info) abrufbar ist. In-Memory pro Session, analog
/// StreamingTracker. Telemetrie darf den Stream nie blockieren → alles defensiv + lock-leicht.
/// </summary>
public class WorkflowProgressTracker
{
private readonly ConcurrentDictionary<string, WorkflowProgress> _progress
= new ConcurrentDictionary<string, WorkflowProgress>();
/// <summary>
/// Verbucht ein Subagent-Event (Tool-Aufrufe + Token-Nutzung eines Subagenten).
/// Lazy: legt den Eintrag bei der ERSTEN Subagent-Aktivität einer Session an, d.h. nur
/// echte Workflow-Läufe (mit gespawnten Subagenten) erzeugen überhaupt einen Eintrag.
/// </summary>
public void RecordSubagent(string sessionId, string chainId, string parentToolUseId,
IReadOnlyList<string> toolNames, long inputTokens, long outputTokens)
{
if (string.IsNullOrEmpty(sessionId)) return;
var wp = _progress.GetOrAdd(sessionId, _ => new WorkflowProgress
{
ChainId = chainId,
StartedAt = DateTime.UtcNow
});
lock (wp.Sync)
{
// Stale-Eintrag eines früheren, nicht sauber finalisierten Laufs (gleiche Session,
// andere Chain) → atomar für die neue Chain zurücksetzen, statt fälschlich zu mischen.
if (wp.ChainId != chainId)
{
wp.ChainId = chainId;
wp.StartedAt = DateTime.UtcNow;
wp.SubagentIds.Clear();
wp.SubagentCount = 0;
wp.SubagentToolCalls = 0;
wp.SubagentInputTokens = 0;
wp.SubagentOutputTokens = 0;
wp.ToolBreakdown.Clear();
wp.RecentTools.Clear();
}
if (!string.IsNullOrEmpty(parentToolUseId) && wp.SubagentIds.Add(parentToolUseId))
wp.SubagentCount = wp.SubagentIds.Count;
if (toolNames != null)
{
foreach (var tn in toolNames)
{
if (string.IsNullOrEmpty(tn)) continue;
wp.SubagentToolCalls++;
wp.ToolBreakdown[tn] = wp.ToolBreakdown.TryGetValue(tn, out var c) ? c + 1 : 1;
wp.RecentTools.Add(tn);
if (wp.RecentTools.Count > 12) wp.RecentTools.RemoveAt(0);
}
}
wp.SubagentInputTokens += inputTokens;
wp.SubagentOutputTokens += outputTokens;
wp.LastActivityAt = DateTime.UtcNow;
}
}
/// <summary>Threadsicherer Live-Snapshot – null, wenn für die Session kein Workflow läuft.</summary>
public WorkflowProgress GetSnapshot(string sessionId)
=> _progress.TryGetValue(sessionId, out var wp) ? wp.Snapshot() : null;
/// <summary>Aktuellen Stand als JSON serialisieren (threadsicher) – null wenn kein Eintrag.</summary>
public string GetJson(string sessionId)
=> _progress.TryGetValue(sessionId, out var wp) ? wp.ToJson() : null;
/// <summary>ChainId des laufenden Workflow-Eintrags (für die Zuordnung beim Finalize).</summary>
public string ChainIdFor(string sessionId)
=> _progress.TryGetValue(sessionId, out var wp) ? wp.ChainId : null;
/// <summary>Entfernt den Eintrag (beim Chain-Finalize aufzurufen).</summary>
public void End(string sessionId) => _progress.TryRemove(sessionId, out _);
}
/// <summary>
/// Telemetrie eines dynamischen Workflows: Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung)
/// und aggregierte Subagent-Tokens. Wird live geführt und beim Chain-Finalize als JSON in
/// chains.workflow_info persistiert.
/// </summary>
public class WorkflowProgress
{
[JsonIgnore] public object Sync { get; } = new object();
// Dedup-Hilfsstruktur (nicht persistiert; SubagentCount trägt den Wert).
[JsonIgnore] public HashSet<string> SubagentIds { get; set; } = new HashSet<string>();
public string ChainId { get; set; }
public DateTime StartedAt { get; set; }
public DateTime LastActivityAt { get; set; }
public int SubagentCount { get; set; }
public int SubagentToolCalls { get; set; }
public long SubagentInputTokens { get; set; }
public long SubagentOutputTokens { get; set; }
public Dictionary<string, int> ToolBreakdown { get; set; } = new Dictionary<string, int>();
public List<string> RecentTools { get; set; } = new List<string>();
public WorkflowProgress Snapshot()
{
lock (Sync)
{
return new WorkflowProgress
{
ChainId = ChainId,
StartedAt = StartedAt,
LastActivityAt = LastActivityAt,
SubagentCount = SubagentCount,
SubagentToolCalls = SubagentToolCalls,
SubagentInputTokens = SubagentInputTokens,
SubagentOutputTokens = SubagentOutputTokens,
ToolBreakdown = new Dictionary<string, int>(ToolBreakdown),
RecentTools = new List<string>(RecentTools)
};
}
}
// Serialisiert einen entkoppelten Snapshot (Snapshot() kopiert unter Lock) – so läuft die
// Serialisierung außerhalb jeder kritischen Sektion und kann nie auf eine live mutierte
// Collection treffen.
public string ToJson() => JsonSerializer.Serialize(Snapshot());
}