diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs
index d913c68..ab89b79 100644
--- a/Endpoints/ChatEndpoint.cs
+++ b/Endpoints/ChatEndpoint.cs
@@ -245,7 +245,7 @@ public static class ChatEndpoint
{
if (anthropic.IsCliAvailable)
{
- await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, remoteMgr: remoteMgr, workCt: workCt, sseCt: sseCt);
+ await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, agentId: ctx.AgentId, remoteMgr: remoteMgr, workCt: workCt, sseCt: sseCt);
}
else
{
diff --git a/Endpoints/InternalEndpoint.cs b/Endpoints/InternalEndpoint.cs
index 4a73005..81e6d62 100644
--- a/Endpoints/InternalEndpoint.cs
+++ b/Endpoints/InternalEndpoint.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Text.Json;
@@ -19,11 +21,22 @@ public static class InternalEndpoint
///
/// Interner Auth-Token – wird beim Start generiert und an MCP-Server übergeben.
///
- public static string InternalToken { get; } = System.Guid.NewGuid().ToString("N");
+ public static string InternalToken { get; } = Guid.NewGuid().ToString("N");
+
+ ///
+ /// Pending user choices – Key ist sessionId, Value wird completed wenn der User klickt.
+ ///
+ public static readonly ConcurrentDictionary> PendingChoices = new();
public static void MapInternalEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/api/internal/remote-execute", HandleRemoteExecute);
+ app.MapPost("/api/internal/ask-user-choice", HandleAskUserChoice);
+ // 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/internal/memory-read", HandleMemoryRead);
+ app.MapPost("/api/internal/memory-write", HandleMemoryWrite);
+ app.MapPost("/api/internal/memory-list", HandleMemoryList);
}
private static async Task HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr)
@@ -70,4 +83,194 @@ public static class InternalEndpoint
return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 500);
}
}
+
+ ///
+ /// 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).
+ ///
+ private static async Task HandleAskUserChoice(HttpContext ctx)
+ {
+ try
+ {
+ var denied = CheckToken(ctx);
+ if (denied != null) return denied;
+
+ using var reader = new StreamReader(ctx.Request.Body);
+ var body = await reader.ReadToEndAsync();
+ JsonElement json;
+ try { json = JsonSerializer.Deserialize(body); }
+ catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
+
+ var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : "";
+ 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 _);
+ return Results.Ok(new { choice });
+ }
+ catch (OperationCanceledException)
+ {
+ PendingChoices.TryRemove(sessionId, out _);
+ return Results.Json(new { error = "Timeout – keine Auswahl getroffen" }, statusCode: 408);
+ }
+ }
+ catch (Exception ex)
+ {
+ return Results.Json(new { error = ex.Message }, statusCode: 500);
+ }
+ }
+
+ ///
+ /// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus).
+ ///
+ private static async Task HandleChoiceRespond(HttpContext ctx)
+ {
+ try
+ {
+ // Kein Token-Check – wird über normale Auth-Middleware geschützt (Cookie-basiert)
+ using var reader = new StreamReader(ctx.Request.Body);
+ var body = await reader.ReadToEndAsync();
+ JsonElement json;
+ try { json = JsonSerializer.Deserialize(body); }
+ catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
+
+ var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : "";
+ var choice = json.TryGetProperty("choice", out var c) ? c.GetString() ?? "" : "";
+
+ if (string.IsNullOrEmpty(sessionId))
+ return Results.BadRequest(new { error = "sessionId erforderlich" });
+ 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" });
+ }
+ catch (Exception ex)
+ {
+ return Results.Json(new { error = ex.Message }, statusCode: 500);
+ }
+ }
+
+ private static IResult CheckToken(HttpContext ctx)
+ {
+ var token = ctx.Request.Headers["X-Internal-Token"].FirstOrDefault() ?? "";
+ return token != InternalToken ? Results.StatusCode(403) : null;
+ }
+
+ private static async Task HandleMemoryRead(HttpContext ctx, MemoryService memoryService)
+ {
+ try
+ {
+ var denied = CheckToken(ctx);
+ if (denied != null) return denied;
+
+ using var reader = new StreamReader(ctx.Request.Body);
+ var body = await reader.ReadToEndAsync();
+ JsonElement json;
+ try { json = JsonSerializer.Deserialize(body); }
+ catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
+
+ var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
+ var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
+
+ if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
+ if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" });
+
+ var content = memoryService.ReadMemoryFile(agentId, path);
+ if (content == null)
+ return Results.NotFound(new { error = $"Datei nicht gefunden: {path}" });
+
+ return Results.Ok(new { content });
+ }
+ catch (System.Exception ex)
+ {
+ return Results.Json(new { error = ex.Message }, statusCode: 500);
+ }
+ }
+
+ private static async Task HandleMemoryWrite(HttpContext ctx, MemoryService memoryService)
+ {
+ try
+ {
+ var denied = CheckToken(ctx);
+ if (denied != null) return denied;
+
+ using var reader = new StreamReader(ctx.Request.Body);
+ var body = await reader.ReadToEndAsync();
+ JsonElement json;
+ try { json = JsonSerializer.Deserialize(body); }
+ catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
+
+ var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
+ var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
+ var content = json.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
+ var append = json.TryGetProperty("append", out var ap) && ap.GetBoolean();
+
+ if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
+ if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" });
+
+ if (append)
+ memoryService.AppendMemoryFile(agentId, path, content);
+ else
+ memoryService.WriteMemoryFile(agentId, path, content);
+
+ return Results.Ok(new { success = true, message = $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)" });
+ }
+ catch (System.Exception ex)
+ {
+ return Results.Json(new { error = ex.Message }, statusCode: 500);
+ }
+ }
+
+ private static async Task HandleMemoryList(HttpContext ctx, MemoryService memoryService)
+ {
+ try
+ {
+ var denied = CheckToken(ctx);
+ if (denied != null) return denied;
+
+ using var reader = new StreamReader(ctx.Request.Body);
+ var body = await reader.ReadToEndAsync();
+ JsonElement json;
+ try { json = JsonSerializer.Deserialize(body); }
+ catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
+
+ var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
+ var subPath = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
+
+ if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
+
+ var files = memoryService.ListMemoryFiles(agentId, subPath);
+ return Results.Ok(new { files });
+ }
+ catch (System.Exception ex)
+ {
+ return Results.Json(new { error = ex.Message }, statusCode: 500);
+ }
+ }
}
diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs
index 2d7f80f..b503b6c 100644
--- a/Services/AnthropicService.cs
+++ b/Services/AnthropicService.cs
@@ -9,6 +9,7 @@ using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Gott.Endpoints;
using Gott.Models;
using Microsoft.AspNetCore.Http;
@@ -1737,7 +1738,7 @@ public class AnthropicService
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
/// Enthält den MCP-Server für remote_execute.
///
- private string GenerateMcpConfig(string sessionId)
+ private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false)
{
// Port aus Konfiguration oder Default
var port = 19000;
@@ -1746,12 +1747,20 @@ public class AnthropicService
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
if (!File.Exists(scriptPath))
{
- // wwwroot ist typischerweise /app/gott/wwwroot → eine Ebene hoch = Projekt-Root
var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory;
var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py");
if (File.Exists(srcPath)) scriptPath = srcPath;
}
+ var env = new Dictionary
+ {
+ ["GOTT_API_URL"] = $"http://localhost:{port}",
+ ["GOTT_SESSION_ID"] = sessionId ?? "",
+ ["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken,
+ ["GOTT_AGENT_ID"] = agentId ?? "",
+ ["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false"
+ };
+
var config = new
{
mcpServers = new Dictionary
@@ -1760,12 +1769,7 @@ public class AnthropicService
{
command = "python3",
args = new[] { scriptPath },
- env = new Dictionary
- {
- ["GOTT_API_URL"] = $"http://localhost:{port}",
- ["GOTT_SESSION_ID"] = sessionId,
- ["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken
- }
+ env
}
}
};
@@ -1929,6 +1933,7 @@ public class AnthropicService
bool planMode = false,
bool webSearch = false,
string sessionId = null,
+ string agentId = null,
RemoteConnectionManager remoteMgr = null,
CancellationToken workCt = default,
CancellationToken sseCt = default)
@@ -1955,11 +1960,9 @@ public class AnthropicService
try
{
- // MCP-Config für remote_execute generieren (wenn Remote-Verbindung vorhanden)
- if (remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null)
- {
- mcpConfigPath = GenerateMcpConfig(sessionId);
- }
+ // MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute)
+ var hasRemote = remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null;
+ mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote);
// System-Prompt als Text extrahieren
var systemText = new StringBuilder();
@@ -1974,19 +1977,6 @@ public class AnthropicService
}
}
- // ask_user_choice Instruktion anhängen
- if (allowUserChoice)
- {
- systemText.AppendLine();
- systemText.AppendLine(@"## Rückfragen
-Wenn du dem User eine Frage mit Auswahlmöglichkeiten stellen möchtest (2-5 Optionen), gib EXAKT diesen Block aus, auf eigenen Zeilen, ohne weiteren Text drumherum:
-
-:::ASK_USER_CHOICE:::
-{""question"": ""Deine Frage"", ""choices"": [""Option A"", ""Option B""]}
-:::END_ASK_USER_CHOICE:::
-
-Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die Entscheidung wirklich vom User getroffen werden muss.");
- }
// Letzte User-Message als Prompt extrahieren
var prompt = ExtractLastUserText(messages) ?? "";
@@ -2060,6 +2050,36 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
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(mappedInput);
+ var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
+ var cChoices = new List();
+ if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
+ foreach (var cOpt in cc.EnumerateArray())
+ cChoices.Add(cOpt.GetString() ?? "");
+
+ // TCS erstellen bevor der MCP-Server den Endpoint aufruft
+ InternalEndpoint.PendingChoices[sessionId] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ // 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:
@@ -2085,43 +2105,6 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
}, sseCt);
break;
}
- case CliEventType.UserChoice:
- // Text bisher in DB speichern
- if (fullText.Length > 0)
- {
- await _db.SaveMessage(new MessageRecord
- {
- ChainId = chainId,
- Iteration = 1,
- Role = "assistant",
- Type = "text",
- Content = fullText.ToString()
- });
- }
-
- // Synthetische tool_use speichern (für History-Rebuild)
- choiceToolCallId = $"toolu_cli_{Guid.NewGuid():N}";
- await _db.SaveMessage(new MessageRecord
- {
- ChainId = chainId,
- Iteration = 1,
- Role = "assistant",
- Type = "tool_use",
- ToolName = "ask_user_choice",
- ToolCallId = choiceToolCallId,
- Content = JsonSerializer.Serialize(new { question = evt.ChoiceQuestion, choices = evt.ChoiceOptions })
- });
-
- // SSE user_choice Event (Frontend zeigt Buttons)
- await SseHelper.TryWriteSse(response, "user_choice", new
- {
- toolCallId = choiceToolCallId,
- question = evt.ChoiceQuestion,
- choices = evt.ChoiceOptions
- }, sseCt);
-
- isUserChoice = true;
- break;
}
},
modelId: modelId,
diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs
index 5e15290..5ddd62b 100644
--- a/Services/ClaudeCliService.cs
+++ b/Services/ClaudeCliService.cs
@@ -113,7 +113,14 @@ public class ClaudeCliService
}
if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); }
// MCP-Tools erlauben (wenn MCP-Config vorhanden)
- if (!string.IsNullOrEmpty(mcpConfigPath)) { tools.Add("mcp__gott-remote__remote_execute"); }
+ if (!string.IsNullOrEmpty(mcpConfigPath))
+ {
+ tools.Add("mcp__gott-remote__remote_execute");
+ tools.Add("mcp__gott-remote__ask_user_choice");
+ tools.Add("mcp__gott-remote__memory_read");
+ tools.Add("mcp__gott-remote__memory_write");
+ tools.Add("mcp__gott-remote__memory_list");
+ }
args.Add("--allowed-tools");
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
@@ -161,11 +168,7 @@ public class ClaudeCliService
var currentToolInput = new StringBuilder();
bool inTextBlock = false;
- // Tracking für ask_user_choice Marker-Erkennung
var lineBuffer = new StringBuilder();
- bool inChoiceCapture = false;
- var choiceJsonBuffer = new StringBuilder();
- bool userChoiceDetected = false;
try
{
@@ -286,9 +289,8 @@ public class ClaudeCliService
if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt))
{
var text = txt.GetString();
- if (!string.IsNullOrEmpty(text) && !userChoiceDetected)
+ if (!string.IsNullOrEmpty(text))
{
- // Zeilenbasierte Marker-Erkennung für :::ASK_USER_CHOICE:::
for (int ci = 0; ci < text.Length; ci++)
{
var ch = text[ci];
@@ -297,52 +299,8 @@ public class ClaudeCliService
var completedLine = lineBuffer.ToString().Trim();
lineBuffer.Clear();
- if (inChoiceCapture)
+ if (completedLine.Length > 0 || fullOutput.Length > 0)
{
- if (completedLine == ":::END_ASK_USER_CHOICE:::")
- {
- // JSON parsen und UserChoice-Event emittieren
- try
- {
- var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString());
- var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
- var choices = new List();
- if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
- foreach (var cOpt in cc.EnumerateArray())
- choices.Add(cOpt.GetString() ?? "");
-
- userChoiceDetected = true;
- await onEvent(new CliStreamEvent
- {
- Type = CliEventType.UserChoice,
- ChoiceQuestion = question,
- ChoiceOptions = choices
- });
- }
- catch (JsonException)
- {
- // Fehlerhaftes JSON → als normalen Text weiterleiten
- var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n:::END_ASK_USER_CHOICE:::\n";
- fullOutput.Append(fallback);
- await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback });
- }
- inChoiceCapture = false;
- choiceJsonBuffer.Clear();
- }
- else
- {
- choiceJsonBuffer.AppendLine(completedLine);
- }
- }
- else if (completedLine == ":::ASK_USER_CHOICE:::")
- {
- inChoiceCapture = true;
- choiceJsonBuffer.Clear();
- }
- else if (completedLine.Length > 0 || fullOutput.Length > 0)
- {
- // Normale Zeile → Text weiterleiten (inkl. Newline)
- // Leere Zeilen am Anfang überspringen (vor dem eigentlichen Text)
var lineText = completedLine + "\n";
fullOutput.Append(lineText);
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText });
@@ -401,41 +359,12 @@ public class ClaudeCliService
else if (type == "result")
{
// Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile)
- if (lineBuffer.Length > 0 && !userChoiceDetected)
+ if (lineBuffer.Length > 0)
{
var remaining = lineBuffer.ToString().Trim();
lineBuffer.Clear();
- if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::")
- {
- // End-Marker ohne trailing \n → Capture abschließen
- try
- {
- var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString());
- var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
- var choices = new List();
- if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
- foreach (var cOpt in cc.EnumerateArray())
- choices.Add(cOpt.GetString() ?? "");
-
- userChoiceDetected = true;
- await onEvent(new CliStreamEvent
- {
- Type = CliEventType.UserChoice,
- ChoiceQuestion = question,
- ChoiceOptions = choices
- });
- }
- catch (JsonException)
- {
- var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n";
- fullOutput.Append(fallback);
- await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback });
- }
- inChoiceCapture = false;
- choiceJsonBuffer.Clear();
- }
- else if (!inChoiceCapture)
+ if (remaining.Length > 0)
{
fullOutput.Append(remaining);
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
@@ -519,40 +448,12 @@ public class ClaudeCliService
}
// Restlichen lineBuffer flushen
- if (lineBuffer.Length > 0 && !userChoiceDetected)
+ if (lineBuffer.Length > 0)
{
var remaining = lineBuffer.ToString().Trim();
lineBuffer.Clear();
- if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::")
- {
- try
- {
- var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString());
- var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
- var choices = new List();
- if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
- foreach (var cOpt in cc.EnumerateArray())
- choices.Add(cOpt.GetString() ?? "");
-
- userChoiceDetected = true;
- await onEvent(new CliStreamEvent
- {
- Type = CliEventType.UserChoice,
- ChoiceQuestion = question,
- ChoiceOptions = choices
- });
- }
- catch (JsonException)
- {
- var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n";
- fullOutput.Append(fallback);
- await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback });
- }
- inChoiceCapture = false;
- choiceJsonBuffer.Clear();
- }
- else if (!inChoiceCapture)
+ if (remaining.Length > 0)
{
fullOutput.Append(remaining);
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
@@ -578,8 +479,7 @@ public class ClaudeCliService
result.ExitCode = process.ExitCode;
result.Output = fullOutput.ToString().Trim();
- result.Success = process.ExitCode == 0 && (fullOutput.Length > 0 || userChoiceDetected);
- result.UserChoiceDetected = userChoiceDetected;
+ result.Success = process.ExitCode == 0 && fullOutput.Length > 0;
if (!result.Success && string.IsNullOrEmpty(result.Error))
result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}";
@@ -696,8 +596,7 @@ public enum CliEventType
TextEnd,
ToolStart,
ToolEnd,
- ToolResult,
- UserChoice
+ ToolResult
}
public class CliStreamEvent
@@ -709,8 +608,6 @@ public class CliStreamEvent
public string ToolInput { get; set; }
public string ToolResultContent { get; set; }
public bool ToolResultIsError { get; set; }
- public string ChoiceQuestion { get; set; }
- public List ChoiceOptions { get; set; }
}
public class CliChatResult
@@ -727,7 +624,6 @@ public class CliChatResult
public long DurationApiMs { get; set; }
public string SessionId { get; set; }
public int NumTurns { get; set; }
- public bool UserChoiceDetected { get; set; }
}
public class CliStatusResult
diff --git a/scripts/mcp-remote-execute.py b/scripts/mcp-remote-execute.py
index da0b274..727c902 100644
--- a/scripts/mcp-remote-execute.py
+++ b/scripts/mcp-remote-execute.py
@@ -19,13 +19,90 @@ import os
API_URL = os.environ.get("GOTT_API_URL", "http://localhost:19000")
SESSION_ID = os.environ.get("GOTT_SESSION_ID", "")
AUTH_TOKEN = os.environ.get("GOTT_AUTH_TOKEN", "")
+AGENT_ID = os.environ.get("GOTT_AGENT_ID", "")
+HAS_REMOTE = os.environ.get("GOTT_HAS_REMOTE", "false") == "true"
-TOOL_NAME = "remote_execute"
-TOOL_DESCRIPTION = (
- "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. "
- "Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs "
- "(z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden."
-)
+TOOLS = []
+
+# Remote-Execute Tool (nur wenn Remote-Verbindung vorhanden)
+if HAS_REMOTE:
+ TOOLS.append({
+ "name": "remote_execute",
+ "description": (
+ "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. "
+ "Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs "
+ "(z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "command": {"type": "string", "description": "PowerShell-Befehl zum Ausführen auf dem Remote-PC"}
+ },
+ "required": ["command"]
+ }
+ })
+
+# ask_user_choice Tool (immer verfügbar)
+TOOLS.append({
+ "name": "ask_user_choice",
+ "description": (
+ "Stellt dem User eine Frage mit Auswahlmöglichkeiten (2-5 Optionen). "
+ "Der User wählt eine Option und du arbeitest mit seiner Antwort weiter. "
+ "Nutze dies wenn du unsicher bist oder es mehrere sinnvolle Wege gibt."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "question": {"type": "string", "description": "Die Frage an den User"},
+ "choices": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Liste der Auswahlmöglichkeiten (2-5 Optionen)",
+ "minItems": 2,
+ "maxItems": 5
+ }
+ },
+ "required": ["question", "choices"]
+ }
+})
+
+# Memory-Tools (immer verfügbar wenn agentId gesetzt)
+if AGENT_ID:
+ TOOLS.append({
+ "name": "memory_read",
+ "description": "Liest eine Memory-Datei des Agents. Nutze dies um gespeicherte Erinnerungen, Notizen oder Kontextinformationen abzurufen.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string", "description": "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')"}
+ },
+ "required": ["path"]
+ }
+ })
+ TOOLS.append({
+ "name": "memory_write",
+ "description": "Schreibt oder überschreibt eine Memory-Datei. Nutze dies um wichtige Informationen, Entscheidungen oder Präferenzen dauerhaft zu speichern.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string", "description": "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')"},
+ "content": {"type": "string", "description": "Inhalt der Datei"},
+ "append": {"type": "boolean", "description": "Wenn true, wird der Inhalt angehängt statt überschrieben (Default: false)"}
+ },
+ "required": ["path", "content"]
+ }
+ })
+ TOOLS.append({
+ "name": "memory_list",
+ "description": "Listet alle Memory-Dateien des Agents auf.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string", "description": "Optionaler Unterpfad zum Auflisten (leer = Root)"}
+ },
+ "required": []
+ }
+ })
def send_response(response):
@@ -59,112 +136,147 @@ def handle_tools_list(req_id):
"jsonrpc": "2.0",
"id": req_id,
"result": {
- "tools": [
- {
- "name": TOOL_NAME,
- "description": TOOL_DESCRIPTION,
- "inputSchema": {
- "type": "object",
- "properties": {
- "command": {
- "type": "string",
- "description": "PowerShell-Befehl zum Ausführen auf dem Remote-PC"
- }
- },
- "required": ["command"]
- }
- }
- ]
+ "tools": TOOLS
}
}
+def api_call(endpoint, payload):
+ """Macht einen API-Call an gott und gibt das Ergebnis zurück."""
+ 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=200) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+
+
+def make_result(req_id, text, is_error=False):
+ """Erstellt eine MCP Tool-Result Response."""
+ return {
+ "jsonrpc": "2.0",
+ "id": req_id,
+ "result": {
+ "content": [{"type": "text", "text": text}],
+ "isError": is_error
+ }
+ }
+
+
+def handle_remote_execute(req_id, arguments):
+ """Führt remote_execute aus."""
+ command = arguments.get("command", "")
+ if not command:
+ return make_result(req_id, "Fehler: Kein Befehl angegeben", True)
+
+ result = api_call("remote-execute", {"command": command, "sessionId": SESSION_ID})
+
+ output_parts = []
+ if result.get("stdout"):
+ output_parts.append(result["stdout"])
+ if result.get("stderr"):
+ output_parts.append(f"[stderr] {result['stderr']}")
+ if result.get("exitCode", 0) != 0:
+ output_parts.append(f"[Exit Code: {result['exitCode']}]")
+
+ text = "\n".join(output_parts) if output_parts else "(keine Ausgabe)"
+ return make_result(req_id, text, result.get("exitCode", 0) != 0)
+
+
+def handle_memory_read(req_id, arguments):
+ """Liest eine Memory-Datei."""
+ path = arguments.get("path", "")
+ if not path:
+ return make_result(req_id, "Fehler: path erforderlich", True)
+
+ result = api_call("memory-read", {"agentId": AGENT_ID, "path": path})
+ return make_result(req_id, result.get("content", ""))
+
+
+def handle_memory_write(req_id, arguments):
+ """Schreibt eine Memory-Datei."""
+ path = arguments.get("path", "")
+ content = arguments.get("content", "")
+ append = arguments.get("append", False)
+ if not path:
+ return make_result(req_id, "Fehler: path erforderlich", True)
+
+ result = api_call("memory-write", {"agentId": AGENT_ID, "path": path, "content": content, "append": append})
+ return make_result(req_id, result.get("message", "OK"))
+
+
+def handle_memory_list(req_id, arguments):
+ """Listet Memory-Dateien auf."""
+ path = arguments.get("path", "")
+
+ result = api_call("memory-list", {"agentId": AGENT_ID, "path": path})
+ files = result.get("files", [])
+ return make_result(req_id, "\n".join(files) if files else "(keine Memory-Dateien vorhanden)")
+
+
+def handle_ask_user_choice(req_id, arguments):
+ """Stellt dem User eine Frage und wartet auf Antwort (long-poll)."""
+ question = arguments.get("question", "")
+ choices = arguments.get("choices", [])
+ if not question:
+ return make_result(req_id, "Fehler: question erforderlich", True)
+ 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}")
+
+
+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"))
+
+
+TOOL_HANDLERS = {
+ "remote_execute": handle_remote_execute,
+ "ask_user_choice": handle_ask_user_choice,
+ "memory_read": handle_memory_read,
+ "memory_write": handle_memory_write,
+ "memory_list": handle_memory_list,
+}
+
+
def handle_tools_call(req_id, params):
"""Führt ein Tool aus."""
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
- if tool_name != TOOL_NAME:
- return {
- "jsonrpc": "2.0",
- "id": req_id,
- "result": {
- "content": [{"type": "text", "text": f"Unbekanntes Tool: {tool_name}"}],
- "isError": True
- }
- }
+ handler = TOOL_HANDLERS.get(tool_name)
+ if not handler:
+ return make_result(req_id, f"Unbekanntes Tool: {tool_name}", True)
- command = arguments.get("command", "")
- if not command:
- return {
- "jsonrpc": "2.0",
- "id": req_id,
- "result": {
- "content": [{"type": "text", "text": "Fehler: Kein Befehl angegeben"}],
- "isError": True
- }
- }
+ # Prüfen ob das Tool auch in TOOLS registriert ist
+ if not any(t["name"] == tool_name for t in TOOLS):
+ return make_result(req_id, f"Tool nicht verfügbar: {tool_name}", True)
- # API-Call an gott
try:
- url = f"{API_URL}/api/internal/remote-execute"
- payload = json.dumps({
- "command": command,
- "sessionId": SESSION_ID
- }).encode("utf-8")
-
- req = urllib.request.Request(url, data=payload, 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=200) as resp:
- result = json.loads(resp.read().decode("utf-8"))
-
- # Ergebnis formatieren
- output_parts = []
- if result.get("stdout"):
- output_parts.append(result["stdout"])
- if result.get("stderr"):
- output_parts.append(f"[stderr] {result['stderr']}")
- if result.get("exitCode", 0) != 0:
- output_parts.append(f"[Exit Code: {result['exitCode']}]")
-
- text = "\n".join(output_parts) if output_parts else "(keine Ausgabe)"
- is_error = result.get("exitCode", 0) != 0
-
- return {
- "jsonrpc": "2.0",
- "id": req_id,
- "result": {
- "content": [{"type": "text", "text": text}],
- "isError": is_error
- }
- }
-
+ return handler(req_id, arguments)
except urllib.error.HTTPError as e:
body = ""
try:
body = e.read().decode("utf-8")
except Exception:
pass
- return {
- "jsonrpc": "2.0",
- "id": req_id,
- "result": {
- "content": [{"type": "text", "text": f"API-Fehler {e.code}: {body}"}],
- "isError": True
- }
- }
+ return make_result(req_id, f"API-Fehler {e.code}: {body}", True)
except Exception as e:
- return {
- "jsonrpc": "2.0",
- "id": req_id,
- "result": {
- "content": [{"type": "text", "text": f"Fehler: {str(e)}"}],
- "isError": True
- }
- }
+ return make_result(req_id, f"Fehler: {str(e)}", True)
def handle_notifications_initialized(params):
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js
index 1d6e88c..467de8c 100644
--- a/wwwroot/js/api.js
+++ b/wwwroot/js/api.js
@@ -72,6 +72,10 @@ function apiChatChoice(sessionId, choiceToolCallId, choiceResult, signal, planMo
return fetch('/api/chat', { method: 'POST', headers: _jsonHeaders, body: JSON.stringify(payload), signal: signal });
}
+function apiChoiceRespond(sessionId, choice) {
+ return _post('/api/chat/choice-respond', { sessionId: sessionId, choice: choice });
+}
+
function apiStopChat(sessionId) { return _post('/api/chat/stop', { sessionId: sessionId }); }
// ── Agent API ──
diff --git a/wwwroot/js/bubbles.js b/wwwroot/js/bubbles.js
index b013160..c003cbf 100644
--- a/wwwroot/js/bubbles.js
+++ b/wwwroot/js/bubbles.js
@@ -180,7 +180,7 @@ function addWarningBubble(text, streamed = false) {
/**
* Creates a choice bubble element (for both live stream and history rendering).
- * @param {object} opts - { toolCallId, question, choices, selectedChoice, timestamp, messageId, streamed }
+ * @param {object} opts - { toolCallId, question, choices, selectedChoice, timestamp, messageId, streamed, viaCli }
*/
function _createChoiceBubble(opts) {
if (!Array.isArray(opts.choices)) opts.choices = [String(opts.choices)];
@@ -189,6 +189,7 @@ function _createChoiceBubble(opts) {
const div = document.createElement('div');
div.className = 'msg choice-bubble' + (answered ? ' choice-answered' : '');
if (opts.streamed) div.classList.add('streamed');
+ if (opts.viaCli) div.dataset.viaCli = 'true';
div.dataset.toolCallId = opts.toolCallId || '';
if (opts.messageId) div.dataset.messageId = opts.messageId;
@@ -219,8 +220,8 @@ function _createChoiceBubble(opts) {
}
// Wird live aus dem SSE-Stream aufgerufen
-function addChoiceBubble(toolCallId, question, choices, streamed = true) {
- const div = _createChoiceBubble({ toolCallId, question, choices, streamed });
+function addChoiceBubble(toolCallId, question, choices, streamed = true, viaCli = false) {
+ const div = _createChoiceBubble({ toolCallId, question, choices, streamed, viaCli });
chatEl().appendChild(div);
scrollDown();
return div;
diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js
index 71839a8..efba604 100644
--- a/wwwroot/js/chat.js
+++ b/wwwroot/js/chat.js
@@ -78,7 +78,7 @@ function handleStreamEvent(eventType, data) {
case 'user_choice':
endStreamBubble();
- addChoiceBubble(data.toolCallId, data.question, data.choices);
+ addChoiceBubble(data.toolCallId, data.question, data.choices, true, data.viaCli);
break;
// Preparer / Review / Finalizer events (shared pattern)
@@ -425,6 +425,17 @@ async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
});
bubbleDiv.classList.add('choice-answered');
+ // CLI-Modus: Antwort über internen Endpoint an MCP-Server weiterleiten (CLI läuft weiter)
+ if (bubbleDiv.dataset.viaCli === 'true') {
+ try {
+ await apiChoiceRespond(getCurrentSessionId(), choice);
+ } catch (e) {
+ console.error('Choice respond failed:', e);
+ }
+ return;
+ }
+
+ // API-Modus: Normaler Chat-Flow (neue Chain)
await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive));
}
diff --git a/wwwroot/js/markdown.js b/wwwroot/js/markdown.js
index 0b5f9c4..f52fded 100644
--- a/wwwroot/js/markdown.js
+++ b/wwwroot/js/markdown.js
@@ -6,18 +6,6 @@ function renderMarkdown(text) {
if (!text) return '';
var src = text.trim();
- // 0) :::ASK_USER_CHOICE::: Blöcke erkennen und als Platzhalter speichern (vor Markdown-Parsing)
- var choiceBlocks = [];
- src = src.replace(/:::ASK_USER_CHOICE:::\s*\n?([\s\S]*?)\n?\s*:::END_ASK_USER_CHOICE:::/g, function(match, jsonStr) {
- try {
- var data = JSON.parse(jsonStr.trim());
- choiceBlocks.push(data);
- return '%%CHOICE' + (choiceBlocks.length - 1) + '%%';
- } catch(e) {
- return match; // JSON-Fehler → als Text belassen
- }
- });
-
// 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen
// Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$)
var mathBlocks = [];
@@ -107,22 +95,6 @@ function renderMarkdown(text) {
}
});
- // 8) Choice-Platzhalter durch interaktive Buttons ersetzen
- html = html.replace(/%%CHOICE(\d+)%%/g, function(m, idx) {
- var data = choiceBlocks[parseInt(idx)];
- var q = data.question || '';
- var choices = data.choices || [];
- var jsonAttr = esc(JSON.stringify(data));
- var out = '
';
- out += '
' + esc(q) + '
';
- out += '
';
- for (var i = 0; i < choices.length; i++) {
- out += '';
- }
- out += '
';
- return out;
- });
-
return html;
}
diff --git a/wwwroot/js/stream-ui.js b/wwwroot/js/stream-ui.js
index 481eb5c..97307c1 100644
--- a/wwwroot/js/stream-ui.js
+++ b/wwwroot/js/stream-ui.js
@@ -16,15 +16,7 @@ function appendToStream(text) {
if (!_streamDiv) startStreamBubble();
_streamText += text;
- // Wenn ein :::ASK_USER_CHOICE::: Block begonnen aber noch nicht abgeschlossen wurde,
- // nur den Text VOR dem Marker rendern (Marker-Block verstecken bis er komplett ist)
- var displayText = _streamText;
- var choiceStart = displayText.indexOf(':::ASK_USER_CHOICE:::');
- if (choiceStart >= 0 && displayText.indexOf(':::END_ASK_USER_CHOICE:::') < 0) {
- displayText = displayText.substring(0, choiceStart);
- }
-
- _streamDiv.innerHTML = renderMarkdown(displayText);
+ _streamDiv.innerHTML = renderMarkdown(_streamText);
// Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht
const cursor = document.createElement('span');
cursor.className = 'cursor';