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:
1 parent
3e0a69e7a4
commit
92ecaf9dac
8 files changed
+184
-66
No files matched your search
@@ -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,13 +144,9 @@ 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);
|
||||
return Results.Ok(new { success = true });
|
||||
}
|
||||
|
||||
return Results.NotFound(new { error = "Keine ausstehende Auswahl für diese Session" });
|
||||
// Antwort speichern – MCP-Server holt sie beim nächsten Poll ab
|
||||
ChoiceAnswers[sessionId] = choice;
|
||||
return Results.Ok(new { success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user