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:
1 parent
a58f279eaa
commit
f2fd493e5f
12 files changed
+555
-132
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user