fix: ask_user_choice Short-Poll, Freitext-Option, Stop-Kontext-Fix

- Long-Poll durch Short-Poll ersetzt (MCP-Server pollt jede Sekunde statt einer blockierenden HTTP-Anfrage)
- Expliziter Lambda-Rückgabetyp Task<IResult> für ASP.NET Minimal API Endpoints (behebt 200 empty body Bug)
- Freitext-Option bei Choice-Buttons ("Eigene Antwort…") für freie User-Eingabe
- Stop während ask_user_choice markiert Chain als awaiting_user_choice statt stopped (Kontext bleibt erhalten)
- ask_user_choice System-Prompt-Instruktion breiter formuliert (Quiz, Multiple-Choice etc.)
- Text wird jetzt immer in DB gespeichert, auch bei ask_user_choice
- Key-Check Model auf claude-haiku-4-5 geändert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.6 committed 2026-03-18 11:32:25 +01:00
1 parent 3e0a69e7a4
commit 92ecaf9dac
8 files changed
+183 -65

No files matched your search

+17 -39
View File
@@ -24,16 +24,19 @@ public static class InternalEndpoint
public static string InternalToken { get; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Pending user choices – Key ist sessionId, Value wird completed wenn der User klickt.
/// Beantwortete User-Choices – Key ist sessionId, Value ist die gewählte Option.
/// MCP-Server pollt dieses Dictionary (Short-Poll statt Long-Poll).
/// </summary>
public static readonly ConcurrentDictionary<string, TaskCompletionSource<string>> PendingChoices = new();
public static readonly ConcurrentDictionary<string, string> ChoiceAnswers = new();
public static void MapInternalEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/api/internal/remote-execute", HandleRemoteExecute);
app.MapPost("/api/internal/ask-user-choice", HandleAskUserChoice);
// WICHTIG: Expliziter Rückgabetyp Task<IResult> nötig! Ohne ihn interpretiert ASP.NET den Lambda als
// RequestDelegate (Func<HttpContext,Task>) → IResult-Rückgabe wird ignoriert → 200 empty body.
app.MapPost("/api/internal/ask-user-choice", async Task<IResult> (HttpContext ctx) => await HandleAskUserChoice(ctx));
// choice-respond ist NICHT unter /api/internal/ weil es vom Frontend aufgerufen wird (normale Auth)
app.MapPost("/api/chat/choice-respond", HandleChoiceRespond);
app.MapPost("/api/chat/choice-respond", async Task<IResult> (HttpContext ctx) => await HandleChoiceRespond(ctx));
app.MapPost("/api/internal/memory-read", HandleMemoryRead);
app.MapPost("/api/internal/memory-write", HandleMemoryWrite);
app.MapPost("/api/internal/memory-list", HandleMemoryList);
@@ -85,8 +88,8 @@ public static class InternalEndpoint
}
/// <summary>
/// MCP-Server ruft diesen Endpoint auf und blockiert bis der User eine Auswahl trifft.
/// Der TCS wird vorher von AnthropicService erstellt (beim tool_use Event).
/// MCP-Server pollt diesen Endpoint bis der User eine Auswahl getroffen hat.
/// Gibt 200 mit { choice } zurück wenn beantwortet, 404 wenn noch ausstehend.
/// </summary>
private static async Task<IResult> HandleAskUserChoice(HttpContext ctx)
{
@@ -105,34 +108,12 @@ public static class InternalEndpoint
if (string.IsNullOrEmpty(sessionId))
return Results.BadRequest(new { error = "sessionId erforderlich" });
// Warte auf den TCS der von AnthropicService erstellt wurde
if (!PendingChoices.TryGetValue(sessionId, out var tcs))
{
// TCS noch nicht erstellt (Race Condition) – kurz warten und nochmal probieren
for (int i = 0; i < 10; i++)
{
await Task.Delay(500, ctx.RequestAborted);
if (PendingChoices.TryGetValue(sessionId, out tcs)) break;
}
if (tcs == null)
return Results.Json(new { error = "Keine ausstehende Auswahl für diese Session" }, statusCode: 404);
}
// Blockieren bis User antwortet (max 5 Minuten)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
cts.CancelAfter(TimeSpan.FromMinutes(5));
try
{
var choice = await tcs.Task.WaitAsync(cts.Token);
PendingChoices.TryRemove(sessionId, out _);
// Prüfen ob bereits eine Antwort vorliegt (Short-Poll)
if (ChoiceAnswers.TryRemove(sessionId, out var choice))
return Results.Ok(new { choice });
}
catch (OperationCanceledException)
{
PendingChoices.TryRemove(sessionId, out _);
return Results.Json(new { error = "Timeout – keine Auswahl getroffen" }, statusCode: 408);
}
// Noch keine Antwort – MCP-Server soll erneut pollen
return Results.Json(new { status = "pending" }, statusCode: 202);
}
catch (Exception ex)
{
@@ -142,6 +123,7 @@ public static class InternalEndpoint
/// <summary>
/// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus).
/// Speichert die Antwort in ChoiceAnswers – MCP-Server holt sie beim nächsten Poll ab.
/// </summary>
private static async Task<IResult> HandleChoiceRespond(HttpContext ctx)
{
@@ -162,14 +144,10 @@ public static class InternalEndpoint
if (string.IsNullOrEmpty(choice))
return Results.BadRequest(new { error = "choice erforderlich" });
if (PendingChoices.TryGetValue(sessionId, out var tcs))
{
tcs.TrySetResult(choice);
// Antwort speichern – MCP-Server holt sie beim nächsten Poll ab
ChoiceAnswers[sessionId] = choice;
return Results.Ok(new { success = true });
}
return Results.NotFound(new { error = "Keine ausstehende Auswahl für diese Session" });
}
catch (Exception ex)
{
return Results.Json(new { error = ex.Message }, statusCode: 500);
+1 -1
View File
@@ -25,7 +25,7 @@ namespace Gott.Services
public static class AnthropicKeyChecker
{
private const string CheckModel = "claude-sonnet-4-20250514";
private const string CheckModel = "claude-haiku-4-5"; //"claude-sonnet-4-20250514";
public static async Task<KeyCheckResult> CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey)
{
+10 -7
View File
@@ -1950,6 +1950,7 @@ public class AnthropicService
var fullText = new StringBuilder();
var tempImagePaths = new List<string>();
string mcpConfigPath = null;
bool isUserChoice = false;
var chain = new ChainRecord
{
@@ -1996,7 +1997,6 @@ public class AnthropicService
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
}
bool isUserChoice = false;
string choiceToolCallId = null;
var result = await _cliService.ChatStreamingAsync(
@@ -2063,8 +2063,8 @@ public class AnthropicService
foreach (var cOpt in cc.EnumerateArray())
cChoices.Add(cOpt.GetString() ?? "");
// TCS erstellen bevor der MCP-Server den Endpoint aufruft
InternalEndpoint.PendingChoices[sessionId] = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
// 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;
@@ -2133,8 +2133,8 @@ public class AnthropicService
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
}
// Antwort in DB speichern (nur wenn nicht schon im UserChoice-Handler gespeichert)
if (!isUserChoice && fullText.Length > 0)
// Antwort in DB speichern
if (fullText.Length > 0)
{
await _db.SaveMessage(new MessageRecord
{
@@ -2173,13 +2173,16 @@ public class AnthropicService
catch (OperationCanceledException) when (workCt.IsCancellationRequested)
{
// Stop-Button gedrückt → CLI-Prozess wurde gestoppt
await FinalizeChain(chain, usage, 1, "stopped", sw);
// Wenn ask_user_choice aktiv war: als awaiting_user_choice markieren,
// damit die nächste Chain den Kontext (Frage + Optionen) behält.
var stoppedReason = isUserChoice ? "awaiting_user_choice" : "stopped";
await FinalizeChain(chain, usage, 1, stoppedReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "stopped",
stopReason = stoppedReason,
viaCli = true
}, CancellationToken.None);
}
+1 -1
View File
@@ -89,7 +89,7 @@ Alle Dateipfade in Tool-Aufrufen müssen relativ zu diesem Workspace sein oder a
{
Icon = "fa-question-circle",
Label = "Agent · Rückfragen (AllowUserChoice)",
Content = "## Rückfragen\nWenn du dir bei einer Aufgabe unsicher bist oder es mehrere sinnvolle Wege gibt, nutze das `ask_user_choice`-Tool um dem User eine Auswahlliste zu präsentieren. Nutze es immer, wenn eine Entscheidung vom User getroffen werden soll."
Content = "## Rückfragen\nWenn du dem User Optionen, Auswahlmöglichkeiten oder Fragen mit mehreren Antwortmöglichkeiten präsentierst, nutze IMMER das `ask_user_choice`-Tool (bzw. `mcp__gott-remote__ask_user_choice`). Das gilt für:\n- Rückfragen bei Unsicherheit oder mehreren sinnvollen Wegen\n- Quiz-Fragen, Multiple-Choice-Fragen\n- Jede Situation in der der User zwischen Optionen wählen soll\n\nNutze NIEMALS A/B/C/D-Aufzählungen oder nummerierte Listen als Ersatz – IMMER das Tool verwenden."
});
}
+24 -17
View File
@@ -217,7 +217,8 @@ def handle_memory_list(req_id, arguments):
def handle_ask_user_choice(req_id, arguments):
"""Stellt dem User eine Frage und wartet auf Antwort (long-poll)."""
"""Stellt dem User eine Frage und wartet auf Antwort (short-poll)."""
import time
question = arguments.get("question", "")
choices = arguments.get("choices", [])
if not question:
@@ -225,23 +226,29 @@ def handle_ask_user_choice(req_id, arguments):
if not choices or len(choices) < 2:
return make_result(req_id, "Fehler: Mindestens 2 choices erforderlich", True)
# Long-Poll: Blockiert bis der User antwortet (max 5 Min)
# Der Backend-Endpoint /api/internal/ask-user-choice blockiert selbst
result = api_call_long("ask-user-choice", {"sessionId": SESSION_ID})
choice = result.get("choice", "")
return make_result(req_id, f"User hat gewählt: {choice}")
# Short-Poll: Jede Sekunde beim Backend nachfragen ob der User geantwortet hat
for i in range(300): # max 5 Minuten
try:
result = api_call("ask-user-choice", {"sessionId": SESSION_ID})
# 200 = Antwort vorhanden
if "choice" in result:
return make_result(req_id, f"User hat gewählt: {result['choice']}")
except urllib.error.HTTPError as e:
if e.code == 202:
# 202 = noch ausstehend, weiter pollen
pass
else:
body = ""
try:
body = e.read().decode("utf-8")
except Exception:
pass
return make_result(req_id, f"API-Fehler {e.code}: {body}", True)
except Exception as e:
return make_result(req_id, f"Fehler: {str(e)}", True)
time.sleep(1)
def api_call_long(endpoint, payload):
"""API-Call mit langem Timeout (für Long-Poll Endpoints)."""
url = f"{API_URL}/api/internal/{endpoint}"
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
if AUTH_TOKEN:
req.add_header("X-Internal-Token", AUTH_TOKEN)
with urllib.request.urlopen(req, timeout=330) as resp:
return json.loads(resp.read().decode("utf-8"))
return make_result(req_id, "Timeout – keine Auswahl getroffen (5 Minuten)", True)
TOOL_HANDLERS = {
+74
View File
@@ -1280,6 +1280,80 @@
color: var(--muted);
}
/* Freitext-Option */
.choice-custom {
margin-top: var(--badge-spacing);
}
.choice-custom-toggle {
font-size: 0.85em;
color: var(--muted);
text-decoration: none;
}
.choice-custom-toggle:hover {
color: var(--primary);
}
.choice-custom-row {
display: none;
gap: var(--badge-spacing);
align-items: center;
margin-top: var(--badge-spacing);
}
.choice-custom-open .choice-custom-toggle {
display: none;
}
.choice-custom-open .choice-custom-row {
display: flex;
}
.choice-custom-input {
flex: 1;
border: var(--border-width) solid var(--f1-border);
border-radius: var(--border-radius);
background: var(--f1-bg);
color: var(--f1-text);
padding: 0.3em 0.6em;
font-size: 0.9em;
}
.choice-custom-input:focus {
border-color: var(--primary);
outline: none;
}
.choice-custom-send {
border: var(--border-width) solid var(--primary);
border-radius: var(--border-radius);
background: transparent;
color: var(--primary);
cursor: pointer;
padding: 0.3em 0.6em;
font-size: 0.9em;
}
.choice-custom-send:hover {
background: var(--primary);
color: var(--f1-bg);
}
.choice-answered .choice-custom {
display: none;
}
.choice-answered .choice-custom.has-answer {
display: block;
}
.choice-custom-answer {
font-size: 0.85em;
color: var(--primary);
font-style: italic;
}
.inline-choice-block {
margin: var(--gap) 0;
padding: var(--gap);
+42
View File
@@ -215,6 +215,48 @@ function _createChoiceBubble(opts) {
}
div.appendChild(buttonsDiv);
// Freitext-Option (nur wenn noch nicht beantwortet)
if (!answered) {
const customDiv = document.createElement('div');
customDiv.className = 'choice-custom';
const toggleLink = document.createElement('a');
toggleLink.className = 'choice-custom-toggle';
toggleLink.href = '#';
toggleLink.textContent = 'Eigene Antwort…';
toggleLink.addEventListener('click', function(e) {
e.preventDefault();
customDiv.classList.add('choice-custom-open');
customDiv.querySelector('.choice-custom-input').focus();
});
customDiv.appendChild(toggleLink);
const inputRow = document.createElement('div');
inputRow.className = 'choice-custom-row';
const input = document.createElement('input');
input.type = 'text';
input.className = 'choice-custom-input';
input.placeholder = 'Eigene Antwort eingeben…';
var sendCustom = function() {
var text = input.value.trim();
if (!text) return;
handleChoiceClick(opts.toolCallId, text, div);
};
input.addEventListener('keydown', function(e) { if (e.key === 'Enter') sendCustom(); });
var sendBtn = document.createElement('button');
sendBtn.className = 'choice-custom-send';
sendBtn.textContent = '↵';
sendBtn.addEventListener('click', sendCustom);
inputRow.appendChild(input);
inputRow.appendChild(sendBtn);
customDiv.appendChild(inputRow);
div.appendChild(customDiv);
}
if (opts.timestamp) _addTimestamp(div, opts.timestamp);
return div;
}
+14
View File
@@ -415,14 +415,28 @@ async function sendMessageDirect(text) {
async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
// Buttons deaktivieren und gewählte Option markieren
const buttons = bubbleDiv.querySelectorAll('.choice-btn');
var matchedButton = false;
buttons.forEach(btn => {
btn.disabled = true;
if (btn.textContent === choice) {
btn.classList.add('choice-selected');
matchedButton = true;
} else {
btn.classList.add('choice-unselected');
}
});
// Freitext-Antwort: Keine der Buttons matched → Antwort als Text anzeigen
if (!matchedButton) {
var customDiv = bubbleDiv.querySelector('.choice-custom');
if (customDiv) {
customDiv.innerHTML = '';
customDiv.classList.add('has-answer');
var answerSpan = document.createElement('span');
answerSpan.className = 'choice-custom-answer';
answerSpan.textContent = '↳ ' + choice;
customDiv.appendChild(answerSpan);
}
}
bubbleDiv.classList.add('choice-answered');
// CLI-Modus: Antwort über internen Endpoint an MCP-Server weiterleiten (CLI läuft weiter)