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:
1 parent
5b1802efee
commit
b6e546f618
22 files changed
+591
-99
No files matched your search
@@ -307,6 +307,8 @@ public partial class AnthropicService
|
||||
mcpConfigPath: mcpConfigPath,
|
||||
hasUnityMcp: !string.IsNullOrEmpty(unityMcpUrl),
|
||||
effort: effort,
|
||||
sessionId: sessionId,
|
||||
chainId: chainId,
|
||||
cancellationToken: segCts.Token
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user