Opus 4.5+ and Sonnet 4.6+ support a new "effort" knob that controls how
much budget the model spends on thinking and tool calls. Plumb it through
end to end so each agent can pick its own level.
Storage & shape:
- New TEXT column agents.effort (default '') added via TryAddColumn.
- AgentRecord.Effort + INSERT/UPDATE in DatabaseService.Agents.cs.
- ChatContext.Effort + SubAgentContext.Effort so the value travels with the
request alongside model and tool settings.
Agent editor:
- Effort dropdown above git-commit behavior in the agent form (low/medium/
high/xhigh/max plus empty = "use default"). Always visible; on OpenAI
providers the value is sent but silently ignored.
- agent-page.js load/save and AgentEndpoint create/update wired up.
Anthropic API path:
- StreamChat and RunNonStreamingToolLoop add output_config: { effort } when
effort is set and not "high" – empty/null/"high" means "omit and let the
API pick its default".
- Sub-agents (preparer/review/finalizer) get the parameter through
RunAgentChat so each step uses its own agent's effort.
Anthropic CLI path:
- ClaudeCliService.ChatStreamingAsync takes an effort parameter and exports
CLAUDE_CODE_EFFORT_LEVEL on the spawned process. Env-var beats the
--effort flag because it also covers "max" and "ultracode".
- StreamChatViaCli, ChatNonStreamingViaCli, RunSubAgentViaCli, and the
ChatEndpoint anthropic branches all hand the value through.
ARCHITECTURE.md / ARCHITECTURE_QUICK.md updated with the new field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
813 lines
34 KiB
C#
813 lines
34 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Gott.Endpoints;
|
||
using Gott.Models;
|
||
using Microsoft.AspNetCore.Http;
|
||
|
||
namespace Gott.Services;
|
||
|
||
public partial class AnthropicService
|
||
{
|
||
private static string _cachedPythonCmd;
|
||
private static bool _pythonCmdChecked;
|
||
|
||
/// <summary>
|
||
/// Streaming-Chat über die Claude CLI.
|
||
/// Die CLI verwendet das lokale Token aus ~/.claude/.credentials.json.
|
||
/// </summary>
|
||
public async Task StreamChatViaCli(
|
||
HttpResponse response,
|
||
string modelId,
|
||
List<object> systemPrompt,
|
||
List<object> messages,
|
||
string chainId,
|
||
string workspace = null,
|
||
bool allowUserChoice = false,
|
||
bool planMode = false,
|
||
bool webSearch = false,
|
||
string sessionId = null,
|
||
string agentId = null,
|
||
RemoteConnectionManager remoteMgr = null,
|
||
string unityMcpUrl = null,
|
||
string unityMcpToken = null,
|
||
bool useAutoContext = true,
|
||
string effort = null,
|
||
CancellationToken workCt = default,
|
||
CancellationToken sseCt = default)
|
||
{
|
||
if (_cliService == null || !_cliService.IsAvailable)
|
||
{
|
||
await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, sseCt);
|
||
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
|
||
return;
|
||
}
|
||
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var usage = new TokenUsage();
|
||
var fullText = new StringBuilder();
|
||
var tempImagePaths = new List<string>();
|
||
string mcpConfigPath = null;
|
||
bool isUserChoice = false;
|
||
|
||
var chain = new ChainRecord
|
||
{
|
||
ChainId = chainId,
|
||
SessionId = sessionId,
|
||
Model = modelId
|
||
};
|
||
|
||
try
|
||
{
|
||
// MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute)
|
||
var remoteConn = remoteMgr != null && !string.IsNullOrEmpty(sessionId) ? remoteMgr.GetBySessionId(sessionId) : null;
|
||
var hasRemote = remoteConn != null;
|
||
var remoteOs = remoteConn?.OsVersion ?? "";
|
||
mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote, unityMcpUrl, unityMcpToken, remoteOs);
|
||
|
||
// Python-Verfügbarkeit prüfen und ggf. warnen
|
||
if (FindPythonCommand() == null)
|
||
await SseHelper.TryWriteSse(response, "warning", new { message = "⚠ Python wurde nicht gefunden (python3/python/py). MCP-Tools (Remote-Execute, Memory, etc.) sind nicht verfügbar." }, sseCt);
|
||
|
||
// System-Prompt als Text extrahieren
|
||
var systemText = new StringBuilder();
|
||
if (systemPrompt != null)
|
||
{
|
||
foreach (var block in systemPrompt)
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (el.TryGetProperty("text", out var t))
|
||
systemText.AppendLine(t.GetString());
|
||
}
|
||
}
|
||
|
||
|
||
// ── Automatische Kontext-Injektion via Embedding-Suche (wie in StreamChat) ──
|
||
if (useAutoContext && messages.Count > 0)
|
||
{
|
||
try
|
||
{
|
||
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId);
|
||
if (autoCtx != null)
|
||
{
|
||
systemText.AppendLine(autoCtx);
|
||
await SseHelper.TryWriteSse(response, "auto_context", new { context = autoCtx }, sseCt);
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Role = "system",
|
||
Type = "auto_context",
|
||
Content = autoCtx
|
||
});
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"[AutoContext/CLI] Error: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// Letzte User-Message als Prompt extrahieren
|
||
var prompt = ExtractLastUserText(messages) ?? "";
|
||
|
||
// Bild-Attachments aus letzter User-Message als Temp-Dateien speichern
|
||
// (CLI kann Bilder nicht inline empfangen, aber per Read-Tool lesen)
|
||
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
|
||
if (!string.IsNullOrEmpty(imageRefs))
|
||
{
|
||
prompt = imageRefs + "\n" + prompt;
|
||
}
|
||
|
||
// Konversations-History als Kontext zum Prompt hinzufügen
|
||
var historyContext = BuildHistoryContext(messages);
|
||
if (!string.IsNullOrEmpty(historyContext))
|
||
{
|
||
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
|
||
}
|
||
|
||
string choiceToolCallId = null;
|
||
|
||
var result = await _cliService.ChatStreamingAsync(
|
||
prompt,
|
||
async evt =>
|
||
{
|
||
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:
|
||
{
|
||
// 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
|
||
{
|
||
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);
|
||
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
|
||
);
|
||
|
||
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
|
||
if (!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)
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "system",
|
||
Type = "error",
|
||
Content = $"CLI-Fehler: {errorMsg}"
|
||
});
|
||
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
|
||
}
|
||
|
||
// Antwort in DB speichern
|
||
if (fullText.Length > 0)
|
||
{
|
||
var finalText = fullText.ToString();
|
||
var msgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = finalText
|
||
});
|
||
_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);
|
||
|
||
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,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason,
|
||
viaCli = true
|
||
}, sseCt);
|
||
}
|
||
catch (OperationCanceledException) when (workCt.IsCancellationRequested)
|
||
{
|
||
// Stop-Button gedrückt → CLI-Prozess wurde gestoppt
|
||
// 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 = stoppedReason,
|
||
viaCli = true
|
||
}, CancellationToken.None);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Client-Disconnect (sseCt gecancelled, aber NICHT workCt)
|
||
// CLI-Prozess läuft weiter im Hintergrund – hier nur Chain finalisieren
|
||
// (sollte nicht mehr passieren, da CLI jetzt workCt nutzt)
|
||
await FinalizeChain(chain, usage, 1, "client_disconnected", sw);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errorMsg = $"CLI-Fehler: {ex.Message}";
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "system",
|
||
Type = "error",
|
||
Content = errorMsg
|
||
});
|
||
await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, sseCt);
|
||
await FinalizeChain(chain, usage, 1, "error", sw);
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason = "error",
|
||
viaCli = true
|
||
}, sseCt);
|
||
}
|
||
finally
|
||
{
|
||
// Temp-Bilddateien aufräumen
|
||
CleanupTempImages(tempImagePaths);
|
||
|
||
// MCP-Config Temp-Datei aufräumen
|
||
if (mcpConfigPath != null)
|
||
{
|
||
try { File.Delete(mcpConfigPath); } catch { }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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,
|
||
/// damit die History im Web-UI korrekt rekonstruiert werden kann.
|
||
/// </summary>
|
||
public async Task<string> ChatNonStreamingViaCli(
|
||
ChatContext ctx,
|
||
List<object> messages,
|
||
Func<int, Task> onIteration = null,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (_cliService == null || !_cliService.IsAvailable)
|
||
return "";
|
||
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var usage = new TokenUsage();
|
||
var tempImagePaths = new List<string>();
|
||
|
||
var chain = new ChainRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
SessionId = ctx.SessionId,
|
||
Model = ctx.ModelId
|
||
};
|
||
|
||
try
|
||
{
|
||
// System-Prompt als Text
|
||
var systemText = new StringBuilder(ctx.SystemPromptText ?? "");
|
||
|
||
// Auto-Context (analog Streaming-Pfad)
|
||
if ((ctx.Agent?.UseAutoContext ?? true) && messages.Count > 0)
|
||
{
|
||
try
|
||
{
|
||
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, ctx.ChainId, ctx.AgentId);
|
||
if (!string.IsNullOrEmpty(autoCtx))
|
||
{
|
||
systemText.AppendLine(autoCtx);
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Role = "system",
|
||
Type = "auto_context",
|
||
Content = autoCtx
|
||
});
|
||
}
|
||
}
|
||
catch { /* silent – Auto-Context optional */ }
|
||
}
|
||
|
||
// Prompt aus letzter User-Message + Bild-/PDF-Refs + History-Kontext
|
||
var prompt = ExtractLastUserText(messages) ?? "";
|
||
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
|
||
if (!string.IsNullOrEmpty(imageRefs))
|
||
prompt = imageRefs + "\n" + prompt;
|
||
|
||
var historyContext = BuildHistoryContext(messages);
|
||
if (!string.IsNullOrEmpty(historyContext))
|
||
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
|
||
|
||
var fullText = new StringBuilder();
|
||
if (onIteration != null) await onIteration(1);
|
||
|
||
var result = await _cliService.ChatStreamingAsync(
|
||
prompt,
|
||
async evt =>
|
||
{
|
||
switch (evt.Type)
|
||
{
|
||
case CliEventType.TextDelta:
|
||
fullText.Append(evt.Text);
|
||
break;
|
||
case CliEventType.ToolStart:
|
||
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
|
||
if (fullText.Length > 0)
|
||
{
|
||
var textForEmbed = fullText.ToString();
|
||
var preMsgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = textForEmbed
|
||
});
|
||
_embeddingBg?.Enqueue(preMsgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", textForEmbed);
|
||
fullText.Clear();
|
||
}
|
||
if (onIteration != null) await onIteration(1);
|
||
break;
|
||
case CliEventType.ToolEnd:
|
||
{
|
||
var mappedName = MapCliToolName(evt.ToolName);
|
||
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "tool_use",
|
||
ToolName = mappedName,
|
||
ToolCallId = evt.ToolId,
|
||
Content = mappedInput
|
||
});
|
||
break;
|
||
}
|
||
case CliEventType.ToolResult:
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 1,
|
||
Role = "user",
|
||
Type = "tool_result",
|
||
ToolCallId = evt.ToolId,
|
||
Content = evt.ToolResultContent ?? "",
|
||
IsError = evt.ToolResultIsError
|
||
});
|
||
break;
|
||
}
|
||
},
|
||
modelId: ctx.ModelId,
|
||
systemPrompt: systemText.ToString(),
|
||
workspace: ctx.Workspace,
|
||
hasWorkspace: !string.IsNullOrEmpty(ctx.Workspace),
|
||
effort: ctx.Effort,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
|
||
// Finalen Text persistieren
|
||
var finalText = fullText.ToString();
|
||
if (finalText.Length > 0)
|
||
{
|
||
var msgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = finalText
|
||
});
|
||
_embeddingBg?.Enqueue(msgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", finalText);
|
||
}
|
||
|
||
usage.InputTokens = result.InputTokens;
|
||
usage.OutputTokens = result.OutputTokens;
|
||
usage.CacheReadTokens = result.CacheReadTokens;
|
||
usage.CacheCreationTokens = result.CacheCreationTokens;
|
||
|
||
var stopReason = result.Success ? "end_turn" : "error";
|
||
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
|
||
|
||
return finalText;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = 1,
|
||
Role = "system", Type = "error",
|
||
Content = $"CLI-Fehler: {ex.Message}", IsError = true
|
||
});
|
||
await FinalizeChain(chain, usage, 1, "error", sw);
|
||
return "";
|
||
}
|
||
finally
|
||
{
|
||
CleanupTempImages(tempImagePaths);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mappt CLI-Tool-Namen (Edit, Write, Bash, etc.) auf gott-Tool-Namen (edit_file, write_file, etc.).
|
||
/// </summary>
|
||
private static string MapCliToolName(string cliName)
|
||
{
|
||
return cliName switch
|
||
{
|
||
"Edit" => "edit_file",
|
||
"Write" => "write_file",
|
||
"Read" => "read_file",
|
||
"Bash" => "execute",
|
||
"Glob" => "list_files",
|
||
"Grep" => "search_files",
|
||
"WebSearch" => "web_search",
|
||
"WebFetch" => "web_fetch",
|
||
"NotebookEdit" => "notebook_edit",
|
||
"TodoWrite" => "todo_write",
|
||
_ => cliName?.StartsWith("mcp__") == true ? cliName.Split("__").LastOrDefault() ?? cliName : cliName
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mappt CLI-Tool-Input-Felder auf gott-Konventionen (z.B. file_path→path, old_string→old_text).
|
||
/// </summary>
|
||
private static string MapCliToolInput(string cliToolName, string inputJson)
|
||
{
|
||
if (string.IsNullOrEmpty(inputJson)) return inputJson;
|
||
|
||
try
|
||
{
|
||
var input = JsonSerializer.Deserialize<JsonElement>(inputJson);
|
||
if (input.ValueKind != JsonValueKind.Object) return inputJson;
|
||
|
||
switch (cliToolName)
|
||
{
|
||
case "Edit":
|
||
{
|
||
// file_path → path, old_string → old_text, new_string → new_text
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name switch
|
||
{
|
||
"file_path" => "path",
|
||
"old_string" => "old_text",
|
||
"new_string" => "new_text",
|
||
_ => prop.Name
|
||
};
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Write":
|
||
{
|
||
// file_path → path
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name == "file_path" ? "path" : prop.Name;
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Read":
|
||
{
|
||
// file_path → path
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name == "file_path" ? "path" : prop.Name;
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Bash":
|
||
{
|
||
// command bleibt gleich
|
||
return inputJson;
|
||
}
|
||
default:
|
||
return inputJson;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
return inputJson;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut einen Kontext-String aus der Konversations-History.
|
||
/// </summary>
|
||
private static string BuildHistoryContext(List<object> messages)
|
||
{
|
||
var sb = new StringBuilder();
|
||
// Letzte Message überspringen wenn es ein Text-Prompt ist (wird separat als current_request gesendet).
|
||
// Wenn die letzte Message ein tool_result ist (z.B. Choice-Antwort), NICHT überspringen.
|
||
int takeCount = messages.Count;
|
||
if (messages.Count > 0)
|
||
{
|
||
var lastJson = JsonSerializer.Serialize(messages[messages.Count - 1]);
|
||
var lastEl = JsonSerializer.Deserialize<JsonElement>(lastJson);
|
||
bool lastIsToolResult = false;
|
||
if (lastEl.TryGetProperty("content", out var lc) && lc.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var b in lc.EnumerateArray())
|
||
{
|
||
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
|
||
if (bType == "tool_result") { lastIsToolResult = true; break; }
|
||
}
|
||
}
|
||
if (!lastIsToolResult)
|
||
takeCount = messages.Count - 1;
|
||
}
|
||
foreach (var msg in messages.Take(takeCount))
|
||
{
|
||
var json = JsonSerializer.Serialize(msg);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (!el.TryGetProperty("role", out var role)) continue;
|
||
var roleStr = role.GetString() ?? "";
|
||
|
||
if (el.TryGetProperty("content", out var content))
|
||
{
|
||
if (content.ValueKind == JsonValueKind.String)
|
||
{
|
||
sb.AppendLine($"[{roleStr}]: {content.GetString()}");
|
||
}
|
||
else if (content.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var block in content.EnumerateArray())
|
||
{
|
||
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
|
||
if (blockType == "text" && block.TryGetProperty("text", out var txt))
|
||
{
|
||
sb.AppendLine($"[{roleStr}]: {txt.GetString()}");
|
||
}
|
||
else if (blockType == "tool_use" && block.TryGetProperty("name", out var tn) && tn.GetString() == "ask_user_choice")
|
||
{
|
||
if (block.TryGetProperty("input", out var inp))
|
||
{
|
||
var q = inp.TryGetProperty("question", out var qq) ? qq.GetString() ?? "" : "";
|
||
var opts = new List<string>();
|
||
if (inp.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
||
foreach (var o in cc.EnumerateArray()) opts.Add(o.GetString() ?? "");
|
||
sb.AppendLine($"[assistant]: Ich habe gefragt: \"{q}\" mit Optionen: {string.Join(", ", opts)}");
|
||
}
|
||
}
|
||
else if (blockType == "tool_result" && block.TryGetProperty("content", out var trContent))
|
||
{
|
||
sb.AppendLine($"[user]: Du hast gewählt: \"{trContent.GetString()}\"");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return sb.ToString().Trim();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sucht einen verfügbaren Python-Befehl (python3, python, py).
|
||
/// Gibt null zurück, wenn keiner gefunden wird.
|
||
/// </summary>
|
||
private static string FindPythonCommand()
|
||
{
|
||
if (_pythonCmdChecked) return _cachedPythonCmd;
|
||
|
||
foreach (var cmd in new[] { "python3", "python", "py" })
|
||
{
|
||
try
|
||
{
|
||
var psi = new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = cmd,
|
||
Arguments = "--version",
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true
|
||
};
|
||
using var proc = System.Diagnostics.Process.Start(psi);
|
||
if (proc != null)
|
||
{
|
||
proc.WaitForExit(3000);
|
||
if (proc.ExitCode == 0)
|
||
{
|
||
_cachedPythonCmd = cmd;
|
||
_pythonCmdChecked = true;
|
||
Console.WriteLine($"[MCP] Python gefunden: {cmd}");
|
||
return cmd;
|
||
}
|
||
}
|
||
}
|
||
catch { /* Command not found – try next */ }
|
||
}
|
||
_pythonCmdChecked = true;
|
||
_cachedPythonCmd = null;
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
|
||
/// Enthält den MCP-Server für remote_execute.
|
||
/// </summary>
|
||
private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false, string unityMcpUrl = null, string unityMcpToken = null, string remoteOs = null)
|
||
{
|
||
// Port aus Konfiguration oder Default
|
||
var port = 19000;
|
||
var scriptPath = Path.Combine(AppContext.BaseDirectory, "scripts", "mcp-remote-execute.py");
|
||
|
||
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
|
||
if (!File.Exists(scriptPath))
|
||
{
|
||
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<string, string>
|
||
{
|
||
["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",
|
||
["GOTT_REMOTE_OS"] = remoteOs ?? ""
|
||
};
|
||
|
||
var pythonCmd = FindPythonCommand();
|
||
|
||
var mcpServers = new Dictionary<string, object>
|
||
{
|
||
["gott-remote"] = new
|
||
{
|
||
command = pythonCmd ?? "python3",
|
||
args = new[] { scriptPath },
|
||
env
|
||
}
|
||
};
|
||
|
||
if (pythonCmd == null)
|
||
Console.WriteLine("[MCP] ⚠ Python nicht gefunden (python3/python/py) – MCP-Tools werden nicht funktionieren!");
|
||
|
||
// Unity MCP Server (pro Session konfigurierbar)
|
||
// Claude CLI erfordert expliziten "type" – "http" für streamableHttp, "sse" für SSE
|
||
if (!string.IsNullOrEmpty(unityMcpUrl))
|
||
{
|
||
// URL-basierte Typ-Erkennung: /sse → SSE-Transport, sonst HTTP (streamableHttp)
|
||
var mcpType = unityMcpUrl.TrimEnd('/').EndsWith("/sse", StringComparison.OrdinalIgnoreCase) ? "sse" : "http";
|
||
var unityConfig = new Dictionary<string, object>
|
||
{
|
||
["type"] = mcpType,
|
||
["url"] = unityMcpUrl
|
||
};
|
||
if (!string.IsNullOrEmpty(unityMcpToken))
|
||
{
|
||
unityConfig["headers"] = new Dictionary<string, string>
|
||
{
|
||
["Authorization"] = $"Bearer {unityMcpToken}"
|
||
};
|
||
}
|
||
mcpServers["unity"] = unityConfig;
|
||
}
|
||
|
||
var config = new { mcpServers };
|
||
|
||
var json = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||
var tempPath = Path.Combine(Path.GetTempPath(), $"gott-mcp-{sessionId}.json");
|
||
File.WriteAllText(tempPath, json);
|
||
return tempPath;
|
||
}
|
||
}
|