Add live steering injection into running chains

Let the user inject a message into a running chain instead of only being
able to abort it. While a chain streams, typing + Enter sends the text to
a new /api/chat/steer endpoint; it is drained at the next iteration
boundary, wrapped with a hint so the model actively classifies it
("complements the task -> keep working" vs. "changes course -> adjust"),
and stored as a Type="steer" user message so it survives reload.

Shared infrastructure (provider-agnostic):
- SteeringQueue: out-of-band queue keyed by sessionId, mirrored from
  StreamingTracker (Enqueue/Drain/Clear).
- Program.cs registers it as a singleton and passes it to AnthropicService.
- ChatEndpoint: POST /api/chat/steer enqueues only while IsStreaming,
  Clear() in the finally.
- ChatService.BuildHistoryFromDb groups Type="steer" so no two
  consecutive user turns are produced on re-chat.
- Frontend api.js/chat.js/bubbles.js/history.js + chat.css render the
  optimistic steer bubble and the steer_injected SSE event; Enter during
  a stream sends a steer instead of being blocked.

Anthropic API path:
- Drain point B (tool boundary, merged into the tool_result user turn)
  and C (turn end -> inject + continue instead of finalize).

Claude CLI path (kill-and-restart):
- The agentic loop runs inside the claude child, so true mid-turn inject
  is impossible. On a pending steer the child is killed at the next tool
  boundary, progress is rebuilt from the DB as a <work_in_progress>
  transcript, and the CLI is restarted with the steer appended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-02 06:49:58 +02:00
1 parent a58f279eaa
commit f2fd493e5f
12 files changed
+555 -132

No files matched your search

+270 -127
View File
@@ -54,6 +54,7 @@ public partial class AnthropicService
var tempImagePaths = new List<string>();
string mcpConfigPath = null;
bool isUserChoice = false;
CancellationTokenSource segCts = null; // aktuelles CLI-Segment (Kill-and-Restart-Steering)
var chain = new ChainRecord
{
@@ -133,130 +134,238 @@ public partial class AnthropicService
string choiceToolCallId = null;
var result = await _cliService.ChatStreamingAsync(
prompt,
async evt =>
// ── Kill-and-Restart-Steering ──
// Der Agentic-Loop läuft INNERHALB von `claude -p` – gott sieht nur Events,
// echtes Mid-Turn-Inject ist über die CLI nicht möglich (Einwürfe würden nur
// gequeued). Stattdessen: kommt während eines Laufs ein Einwurf rein, killen wir
// den Child am nächsten Tool-Boundary, hängen den bisherigen Verlauf + den Einwurf
// an den Prompt und starten die CLI neu (Abbruch-Semantik – mehr geht über die CLI nicht).
var basePrompt = prompt;
var currentPrompt = prompt;
int totalTurns = 0;
double totalCost = 0;
int steerRestartCount = 0;
CliChatResult result = null;
bool steerRestart;
bool segSteerTriggered = false;
// Event-Handler als lokale Funktion (damit der Restart-Loop flach bleibt)
async Task HandleCliEvent(CliStreamEvent evt)
{
switch (evt.Type)
{
switch (evt.Type)
case CliEventType.TextStart:
await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
break;
case CliEventType.TextDelta:
fullText.Append(evt.Text);
await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt);
break;
case CliEventType.TextEnd:
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
break;
case CliEventType.ToolStart:
{
case CliEventType.TextStart:
await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
break;
case CliEventType.TextDelta:
fullText.Append(evt.Text);
await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt);
break;
case CliEventType.TextEnd:
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
break;
case CliEventType.ToolStart:
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
if (fullText.Length > 0)
{
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
if (fullText.Length > 0)
{
var textForEmbed = fullText.ToString();
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = textForEmbed
});
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed);
fullText.Clear();
}
var mappedName = MapCliToolName(evt.ToolName);
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt);
break;
}
case CliEventType.ToolEnd:
{
var mappedName = MapCliToolName(evt.ToolName);
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
// Tool-Use in DB speichern (damit Poll die Tools anzeigt)
await _db.SaveMessage(new MessageRecord
var textForEmbed = fullText.ToString();
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "tool_use",
ToolName = mappedName,
ToolCallId = evt.ToolId,
Content = mappedInput
Type = "text",
Content = textForEmbed
});
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
if (mappedName == "ask_user_choice")
{
try
{
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
var cChoices = new List<string>();
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
foreach (var cOpt in cc.EnumerateArray())
cChoices.Add(cOpt.GetString() ?? "");
// Alte Antwort entfernen (falls vorhanden)
InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _);
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
choiceToolCallId = evt.ToolId;
await SseHelper.TryWriteSse(response, "user_choice", new
{
toolCallId = evt.ToolId,
question = cQuestion,
choices = cChoices,
viaCli = true
}, sseCt);
isUserChoice = true;
}
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
}
break;
}
case CliEventType.ToolResult:
{
// Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist)
var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? "");
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "user",
Type = "tool_result",
ToolCallId = evt.ToolId,
Content = resultContent,
IsError = evt.ToolResultIsError
});
// SSE Event an Client senden
await SseHelper.TryWriteSse(response, "tool_result", new
{
id = evt.ToolId,
result = resultContent,
isError = evt.ToolResultIsError
}, sseCt);
break;
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed);
fullText.Clear();
}
var mappedName = MapCliToolName(evt.ToolName);
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt);
break;
}
},
modelId: modelId,
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
workspace: workspace,
hasWorkspace: !string.IsNullOrEmpty(workspace),
planMode: planMode,
webSearch: webSearch,
mcpConfigPath: mcpConfigPath,
hasUnityMcp: !string.IsNullOrEmpty(unityMcpUrl),
effort: effort,
cancellationToken: workCt
);
case CliEventType.ToolEnd:
{
var mappedName = MapCliToolName(evt.ToolName);
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
// Tool-Use in DB speichern (damit Poll die Tools anzeigt)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "tool_use",
ToolName = mappedName,
ToolCallId = evt.ToolId,
Content = mappedInput
});
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
if (mappedName == "ask_user_choice")
{
try
{
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
var cChoices = new List<string>();
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
foreach (var cOpt in cc.EnumerateArray())
cChoices.Add(cOpt.GetString() ?? "");
// Alte Antwort entfernen (falls vorhanden)
InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _);
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
choiceToolCallId = evt.ToolId;
await SseHelper.TryWriteSse(response, "user_choice", new
{
toolCallId = evt.ToolId,
question = cQuestion,
choices = cChoices,
viaCli = true
}, sseCt);
isUserChoice = true;
}
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
}
break;
}
case CliEventType.ToolResult:
{
// Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist)
var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? "");
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "user",
Type = "tool_result",
ToolCallId = evt.ToolId,
Content = resultContent,
IsError = evt.ToolResultIsError
});
// SSE Event an Client senden
await SseHelper.TryWriteSse(response, "tool_result", new
{
id = evt.ToolId,
result = resultContent,
isError = evt.ToolResultIsError
}, sseCt);
// Steering: Tool-Boundary erreicht – steht ein Einwurf an, Child killen
// (löst OperationCanceledException aus → Restart unten).
if (!segSteerTriggered && !isUserChoice
&& _steeringQueue != null && !string.IsNullOrEmpty(sessionId)
&& _steeringQueue.HasPending(sessionId))
{
segSteerTriggered = true;
try { segCts?.Cancel(); } catch { }
}
break;
}
}
}
do
{
steerRestart = false;
segSteerTriggered = false;
segCts?.Dispose();
segCts = CancellationTokenSource.CreateLinkedTokenSource(workCt);
try
{
result = await _cliService.ChatStreamingAsync(
currentPrompt,
HandleCliEvent,
modelId: modelId,
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
workspace: workspace,
hasWorkspace: !string.IsNullOrEmpty(workspace),
planMode: planMode,
webSearch: webSearch,
mcpConfigPath: mcpConfigPath,
hasUnityMcp: !string.IsNullOrEmpty(unityMcpUrl),
effort: effort,
cancellationToken: segCts.Token
);
}
catch (OperationCanceledException) when (segSteerTriggered && !workCt.IsCancellationRequested)
{
// Steering-Kill (kein echter Abbruch) → Restart unten vorbereiten
result = null;
}
// Tokens des abgeschlossenen Segments akkumulieren (bei Kill: result == null)
if (result != null)
{
usage.InputTokens += result.InputTokens;
usage.OutputTokens += result.OutputTokens;
usage.CacheReadTokens += result.CacheReadTokens;
usage.CacheCreationTokens += result.CacheCreationTokens;
totalCost += result.CostUsd;
totalTurns += result.NumTurns > 0 ? result.NumTurns : 1;
}
// Restart nötig? (a) Kill am Tool-Boundary, (b) Einwurf kam erst am Turn-Ende rein
bool steerPending = !isUserChoice && _steeringQueue != null
&& !string.IsNullOrEmpty(sessionId) && _steeringQueue.HasPending(sessionId);
if (segSteerTriggered || steerPending)
{
steerRestart = true;
Console.WriteLine($"[CLI-Steering] Restart #{++steerRestartCount} (chain {chainId})");
// 1. Bisher gepufferten Assistant-Text dieses Segments persistieren
if (fullText.Length > 0)
{
var partial = fullText.ToString();
var pid = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = 1,
Role = "assistant", Type = "text", Content = partial
});
_embeddingBg?.Enqueue(pid, chainId, sessionId, agentId, "assistant", partial);
fullText.Clear();
}
// 2. Verlauf dieses Durchlaufs aus der DB als Transkript (vor dem Persistieren der neuen Einwürfe)
var progressLog = await BuildCliProgressLog(chainId);
// 3. Einwürfe entnehmen, RAW persistieren (Type=steer), via SSE melden, gewrappt sammeln
var steers = _steeringQueue.Drain(sessionId);
var wrapped = new StringBuilder();
foreach (var steer in steers)
{
var sid = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = 1,
Role = "user", Type = "steer", Content = steer
});
_embeddingBg?.Enqueue(sid, chainId, sessionId, agentId, "user", steer);
await SseHelper.TryWriteSse(response, "steer_injected", new { text = steer }, sseCt);
wrapped.AppendLine(WrapSteeringMessage(steer));
}
// 4. Restart-Prompt: Basis-Prompt + bisheriger Verlauf + Einwürfe
var sb = new StringBuilder(basePrompt);
if (!string.IsNullOrEmpty(progressLog))
{
sb.Append("\n\n<work_in_progress>\nDu arbeitest bereits an obiger Aufgabe. Bisheriger Verlauf in diesem Durchlauf (Text, Tool-Aufrufe, Ergebnisse):\n");
sb.Append(progressLog);
sb.Append("\n</work_in_progress>");
}
sb.Append("\n\n");
sb.Append(wrapped.ToString().Trim());
currentPrompt = sb.ToString();
}
}
while (steerRestart && !workCt.IsCancellationRequested);
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
if (!result.Success && !isUserChoice)
if (result != null && !result.Success && !isUserChoice)
{
var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert";
// Error in DB speichern (damit er beim Neuladen sichtbar ist)
@@ -271,7 +380,7 @@ public partial class AnthropicService
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
}
// Antwort in DB speichern
// Antwort in DB speichern (finaler Text des letzten Segments)
if (fullText.Length > 0)
{
var finalText = fullText.ToString();
@@ -286,24 +395,19 @@ public partial class AnthropicService
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", finalText);
}
// Token-Usage vom CLI-Result übernehmen
usage.InputTokens = result.InputTokens;
usage.OutputTokens = result.OutputTokens;
usage.CacheReadTokens = result.CacheReadTokens;
usage.CacheCreationTokens = result.CacheCreationTokens;
var stopReason = isUserChoice ? "awaiting_user_choice" : (result.Success ? "end_turn" : "error");
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
// Token-Usage wurde segmentweise in usage/totalTurns/totalCost akkumuliert (siehe Restart-Loop)
var stopReason = isUserChoice ? "awaiting_user_choice" : (result != null && result.Success ? "end_turn" : "error");
await FinalizeChain(chain, usage, totalTurns > 0 ? totalTurns : 1, stopReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = result.InputTokens,
outputTokens = result.OutputTokens,
cacheCreationTokens = result.CacheCreationTokens,
cacheReadTokens = result.CacheReadTokens,
costUsd = result.CostUsd,
iterations = result.NumTurns > 0 ? result.NumTurns : 1,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
cacheCreationTokens = usage.CacheCreationTokens,
cacheReadTokens = usage.CacheReadTokens,
costUsd = totalCost,
iterations = totalTurns > 0 ? totalTurns : 1,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason,
@@ -360,6 +464,8 @@ public partial class AnthropicService
}
finally
{
segCts?.Dispose();
// Temp-Bilddateien aufräumen
CleanupTempImages(tempImagePaths);
@@ -371,6 +477,43 @@ public partial class AnthropicService
}
}
/// <summary>
/// Baut aus den bereits gespeicherten Messages einer Chain ein kompaktes Verlauf-Transkript
/// für den Kill-and-Restart-Steering-Prompt (was hat das Modell in diesem Durchlauf bereits getan).
/// </summary>
private async Task<string> BuildCliProgressLog(string chainId)
{
var msgs = await _db.GetChainMessages(chainId);
var sb = new StringBuilder();
foreach (var m in msgs)
{
switch (m.Type)
{
case "text":
if (m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content))
sb.AppendLine("[Du]: " + m.Content.Trim());
break;
case "tool_use":
sb.AppendLine($"[Du hast aufgerufen: {m.ToolName}] {TruncateForLog(m.Content, 600)}");
break;
case "tool_result":
sb.AppendLine($"[Ergebnis{(m.IsError ? " (Fehler)" : "")}]: {TruncateForLog(m.Content, 600)}");
break;
case "steer":
sb.AppendLine("[Vorheriger Live-Einwurf des Users]: " + (m.Content ?? "").Trim());
break;
}
}
return sb.ToString().Trim();
}
private static string TruncateForLog(string s, int max)
{
if (string.IsNullOrEmpty(s)) return "";
s = s.Replace("\r", " ").Replace("\n", " ").Trim();
return s.Length <= max ? s : s.Substring(0, max) + " …[gekürzt]";
}
/// <summary>
/// Non-Streaming Chat über die Claude CLI für Telegram (und andere Caller, die kein SSE brauchen).
/// Sammelt den finalen Text und gibt ihn zurück. Persistiert Text, Tool-Use und Tool-Result in der DB,
+62
View File
@@ -14,6 +14,45 @@ namespace Gott.Services;
public partial class AnthropicService
{
/// <summary>
/// Verpackt einen Live-Einwurf (Steering) des Users mit einem Hinweis, damit das
/// Modell ihn aktiv einordnet statt ihn nur passiv anzuhängen: laufende Aufgabe
/// ergänzen ODER Kurs ändern.
/// </summary>
private static string WrapSteeringMessage(string text)
{
return "[Live-Einwurf vom User – kam herein, während du an der laufenden Aufgabe arbeitest]:\n"
+ text
+ "\n\n<hint>Berücksichtige diesen Einwurf ab sofort. Ergänzt er nur die laufende Aufgabe → arbeite weiter und beziehe ihn ein. Ändert er den Kurs → passe dein Vorgehen an und sag in einem Satz, was du jetzt anders machst.</hint>";
}
/// <summary>
/// Draint die Steering-Queue der Session und hängt etwaige Live-Einwürfe als
/// Text-Blöcke (mit Hinweis-Wrapper) an die übergebene Content-Liste eines
/// user-Turns. Persistiert jeden Einwurf RAW (Role=user, Type=steer) für History +
/// Re-Chat und meldet ihn via SSE (steer_injected) ans Frontend.
/// Gibt die Anzahl injizierter Einwürfe zurück.
/// </summary>
private async Task<int> InjectSteeringAsync(HttpResponse response, List<object> content, string chainId, string sessionId, string agentId, int iteration, CancellationToken sseCt)
{
if (_steeringQueue == null || string.IsNullOrEmpty(sessionId)) return 0;
var steers = _steeringQueue.Drain(sessionId);
foreach (var steer in steers)
{
// API: gewrappter Text-Block (mit Hinweis). DB: RAW-Text (sauber für History/Re-Chat).
content.Add(new { type = "text", text = WrapSteeringMessage(steer) });
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iteration,
Role = "user", Type = "steer",
Content = steer
});
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "user", steer);
await SseHelper.TryWriteSse(response, "steer_injected", new { text = steer }, sseCt);
}
return steers.Count;
}
public async Task StreamChat(
HttpResponse response,
string modelId,
@@ -288,6 +327,23 @@ public partial class AnthropicService
iterations += finalizerIterations;
}
// ── Steering-Injektion (Drain-Punkt C) ──
// Das Modell würde jetzt seinen Turn beenden. Hat der User einen Einwurf
// geschickt (während des Turns oder der Review/Finalizer-Phase), injizieren
// wir ihn als neuen user-Turn und lassen die Chain WEITERlaufen statt zu
// finalisieren. NICHT bei awaiting_user_choice – dort wartet die Chain
// bewusst auf die Choice-Antwort des Users.
if (stopReason != "awaiting_user_choice")
{
var steerContent = new List<object>();
var injectedCount = await InjectSteeringAsync(response, steerContent, chainId, sessionId, agentId, iterations, sseCt);
if (injectedCount > 0)
{
messages.Add(new { role = "user", content = steerContent });
continue; // → Chain läuft weiter
}
}
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
await SseHelper.TryWriteSse(response, "done", new
@@ -320,6 +376,12 @@ public partial class AnthropicService
});
}
// ── Steering-Injektion (Drain-Punkt B) ──
// Live-Einwürfe, die während dieser Tool-Runde eingingen, an DENSELBEN
// user-Turn anhängen (tool_result-Blöcke zuerst, dann Text – API-konform).
// So sieht das Modell den Einwurf in der nächsten Iteration, mitten in der Arbeit.
await InjectSteeringAsync(response, toolResults, chainId, sessionId, agentId, iterations, sseCt);
messages.Add(new { role = "user", content = toolResults });
// Hard-Stop Check nach Tool-Ausführung: Sofort raus statt nächste Iteration
+3 -1
View File
@@ -43,11 +43,12 @@ public partial class AnthropicService
private readonly ServerSettingsService _settings;
private readonly VectorSearchService _vectorSearch;
private readonly EmbeddingBackgroundService _embeddingBg;
private readonly SteeringQueue _steeringQueue;
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)
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, SteeringQueue steeringQueue = null)
{
_httpFactory = httpFactory;
_toolService = toolService;
@@ -56,6 +57,7 @@ public partial class AnthropicService
_settings = settings;
_vectorSearch = vectorSearch;
_embeddingBg = embeddingBg;
_steeringQueue = steeringQueue;
}
public void SetCliService(ClaudeCliService cliService)
+10 -1
View File
@@ -294,7 +294,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
{
foreach (var msg in msgs)
{
if (msg.Role == "user" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content))
if (msg.Role == "user" && (msg.Type == "text" || msg.Type == "steer") && !string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
@@ -362,6 +362,15 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
if (content.Count > 0)
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = content });
}
else if (msg.Role == "user" && msg.Type == "steer")
{
// Live-Einwurf (Steering) des Users während die Chain lief.
// AppendToLastMessage mergt ihn in den vorausgehenden user-Turn
// (tool_result-Runde bei Drain-B) bzw. erzeugt einen neuen user-Turn
// nach dem Assistant (Drain-C) → niemals zwei aufeinanderfolgende user-Turns.
if (!string.IsNullOrEmpty(msg.Content))
AppendToLastMessage(history, "user", new { type = "text", text = msg.Content });
}
else if (msg.Role == "assistant" && msg.Type == "thinking" && includeThinking)
{
// Thinking-Inhalt für OpenAI-kompatible Provider mitführen.
+54
View File
@@ -0,0 +1,54 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace Gott.Services;
/// <summary>
/// Out-of-band-Kanal für "Steering"-Einwürfe: Nachrichten, die der User schickt
/// WÄHREND eine Chain läuft (vgl. /btw bzw. Queued-Steering in Claude Code).
/// 1:1 gespiegelt vom <see cref="StreamingTracker"/> – keyed by sessionId.
///
/// Der Streaming-Loop (AnthropicService.StreamChat) draint die Queue an den
/// Iterations-Grenzen (nach einer Tool-Runde bzw. bevor die Chain finalisiert)
/// und injiziert die Texte als User-Message in die laufende Konversation.
/// Ein laufender messages.create-Request ist unveränderlich – Injektion geht
/// nur ZWISCHEN den API-Calls, nicht mitten im Stream.
///
/// Im CLI-Pfad (AnthropicService.StreamChatViaCli) läuft der Agentic-Loop im
/// claude-Child; dort draint ein Kill-and-Restart am nächsten Tool-Boundary
/// (Child killen, Verlauf + Einwurf an den Prompt hängen, CLI neu starten).
/// </summary>
public class SteeringQueue
{
private readonly ConcurrentDictionary<string, ConcurrentQueue<string>> _queues = new ConcurrentDictionary<string, ConcurrentQueue<string>>();
/// <summary>Reiht einen Einwurf für eine Session ein (FIFO).</summary>
public void Enqueue(string sessionId, string text)
{
if (string.IsNullOrEmpty(sessionId) || string.IsNullOrWhiteSpace(text)) return;
var q = _queues.GetOrAdd(sessionId, _ => new ConcurrentQueue<string>());
q.Enqueue(text);
}
/// <summary>
/// Entnimmt ALLE pending Einwürfe einer Session (FIFO) und gibt sie zurück.
/// Leere Liste wenn nichts ansteht.
/// </summary>
public List<string> Drain(string sessionId)
{
var result = new List<string>();
if (_queues.TryGetValue(sessionId, out var q))
{
while (q.TryDequeue(out var text))
result.Add(text);
}
return result;
}
/// <summary>Prüft ob für eine Session Einwürfe anstehen.</summary>
public bool HasPending(string sessionId)
=> _queues.TryGetValue(sessionId, out var q) && !q.IsEmpty;
/// <summary>Entfernt die Queue einer Session (beim Chain-Ende aufräumen).</summary>
public void Clear(string sessionId) => _queues.TryRemove(sessionId, out _);
}