using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; namespace Gott.Services; public class ToolDefinitionOptions { public bool IncludeFileTools { get; set; } public bool IncludeMemory { get; set; } public bool IncludeUserChoice { get; set; } = true; public bool IncludeRemote { get; set; } public string RemoteOs { get; set; } public bool IncludeMemorySearch { get; set; } public bool IncludeSendToSession { get; set; } public bool IncludeGitHubSearch { get; set; } public bool IncludeSerperSearch { get; set; } } public class ToolExecutionContext { public string Workspace { get; set; } public MemoryService MemoryService { get; set; } public string AgentId { get; set; } public CancellationToken CancellationToken { get; set; } public RemoteConnectionManager RemoteMgr { get; set; } public string SessionId { get; set; } public VectorSearchService VectorSearch { get; set; } public EmbeddingBackgroundService EmbeddingBg { get; set; } public DatabaseService Db { get; set; } public ChatService ChatService { get; set; } public AnthropicService AnthropicService { get; set; } public OpenAiService OpenAiService { get; set; } public string UserId { get; set; } public TelegramService TelegramService { get; set; } public string ApiToken { get; set; } } public class ToolService { private readonly ServerSettingsService _settings; private IHttpClientFactory _httpFactory; public ToolService(ServerSettingsService settings) { _settings = settings; } public void SetHttpClientFactory(IHttpClientFactory httpFactory) { _httpFactory = httpFactory; } private int ExecuteTimeoutSeconds => _settings.GetInt("ExecuteTimeout", 180); public bool HasGitHubToken => !string.IsNullOrWhiteSpace(_settings.Get("GitHubToken")); public bool HasSerperKey => !string.IsNullOrWhiteSpace(_settings.Get("SerperApiKey")); public const string AskUserChoiceToolName = "ask_user_choice"; /// /// Read-only Tools die im Plan-Modus verfügbar bleiben. /// Write-Tools (write_file, edit_file, delete_file, execute) werden entfernt. /// private static readonly HashSet ReadOnlyToolNames = new HashSet { "read_file", "list_files", "glob", "grep", "execute", "todo_write", "memory_read", "memory_write", "memory_list", "memory_search", "remote_execute", "github_code_search", "serper_web_search", AskUserChoiceToolName }; public object[] FilterReadOnlyTools(object[] tools) { var filtered = new List(); foreach (var tool in tools) { var json = JsonSerializer.Serialize(tool); var el = JsonSerializer.Deserialize(json); if (el.TryGetProperty("name", out var nameProp)) { var name = nameProp.GetString() ?? ""; if (ReadOnlyToolNames.Contains(name)) filtered.Add(tool); } } return filtered.ToArray(); } public object[] GetToolDefinitions(ToolDefinitionOptions opts) { var allTools = new List(); if (opts.IncludeUserChoice) allTools.Add(GetAskUserChoiceTool()); if (opts.IncludeFileTools) allTools.AddRange(GetFileTools()); if (opts.IncludeMemory) allTools.AddRange(GetMemoryToolDefinitions()); if (opts.IncludeMemorySearch) allTools.Add(GetMemorySearchTool()); if (opts.IncludeRemote) allTools.Add(GetRemoteExecuteTool(opts.RemoteOs)); if (opts.IncludeSendToSession) allTools.Add(GetSendToSessionTool()); if (opts.IncludeGitHubSearch) allTools.Add(GetGitHubCodeSearchTool()); if (opts.IncludeSerperSearch) allTools.Add(GetSerperWebSearchTool()); return allTools.ToArray(); } private object GetSendToSessionTool() { return new { name = "send_to_session", description = "Schreibt eine Nachricht in eine andere Session und löst dort eine Antwort des Agents aus. Nützlich um Nachrichten, Grüße oder Aufgaben an einen anderen Agenten in einer anderen Session zu übermitteln.", input_schema = new { type = "object", properties = new Dictionary { ["session_id_or_name"] = new { type = "string", description = "Die ID oder der Name der Ziel-Session" }, ["message"] = new { type = "string", description = "Die Nachricht die in die Ziel-Session geschrieben wird" } }, required = new[] { "session_id_or_name", "message" } } }; } private object GetAskUserChoiceTool() { return new { name = AskUserChoiceToolName, description = "Stellt dem User eine Frage mit Auswahlmöglichkeiten, wenn du unsicher bist oder es mehrere sinnvolle Wege gibt. Der User wählt eine Option und du arbeitest mit seiner Antwort weiter.", input_schema = new { type = "object", properties = new Dictionary { ["question"] = new { type = "string", description = "Die Frage an den User" }, ["choices"] = new { type = "array", description = "Liste der Auswahlmöglichkeiten (2-5 Optionen). MUSS ein JSON-Array mit Strings sein, z.B. [\"Option A\", \"Option B\", \"Option C\"]. Niemals einen einzelnen String verwenden.", items = new { type = "string", description = "Eine einzelne Auswahlmöglichkeit als kurzer, prägnanter Text" }, minItems = 2, maxItems = 5 } }, required = new[] { "question", "choices" } } }; } private object[] GetFileTools() { return new object[] { new { name = "read_file", description = "Liest den Inhalt einer Datei. Gibt den Textinhalt zurück. Standardmäßig werden maximal 2000 Zeilen gelesen. Bei großen Dateien offset und limit verwenden.", input_schema = new { type = "object", properties = new Dictionary { ["path"] = new { type = "string", description = "Relativer Dateipfad" }, ["offset"] = new { type = "integer", description = "Erste Zeile die gelesen werden soll (1-basiert, Default: 1)" }, ["limit"] = new { type = "integer", description = "Maximale Anzahl Zeilen die gelesen werden sollen (Default: 2000)" } }, required = new[] { "path" } } }, new { name = "write_file", description = "Erstellt oder überschreibt eine Datei mit dem angegebenen Inhalt.", input_schema = new { type = "object", properties = new Dictionary { ["path"] = new { type = "string", description = "Relativer Dateipfad" }, ["content"] = new { type = "string", description = "Dateiinhalt" } }, required = new[] { "path", "content" } } }, new { name = "edit_file", description = "Ersetzt einen exakten Textabschnitt in einer Datei. oldText muss exakt matchen.", input_schema = new { type = "object", properties = new Dictionary { ["path"] = new { type = "string", description = "Relativer Dateipfad" }, ["old_text"] = new { type = "string", description = "Exakter Text der ersetzt werden soll" }, ["new_text"] = new { type = "string", description = "Neuer Text" } }, required = new[] { "path", "old_text", "new_text" } } }, new { name = "list_files", description = "Listet Dateien und Ordner in einem Verzeichnis auf.", input_schema = new { type = "object", properties = new Dictionary { ["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" } }, required = Array.Empty() } }, new { name = "delete_file", description = "Löscht eine Datei oder ein leeres Verzeichnis.", input_schema = new { type = "object", properties = new Dictionary { ["path"] = new { type = "string", description = "Relativer Pfad zur Datei oder zum Verzeichnis" } }, required = new[] { "path" } } }, new { name = "execute", description = $"Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: {ExecuteTimeoutSeconds} Sekunden.", input_schema = new { type = "object", properties = new Dictionary { ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" } }, required = new[] { "command" } } }, new { name = "background_execute", description = "Startet einen Shell-Befehl als Hintergrund-Prozess im Workspace-Verzeichnis. Der Prozess läuft unabhängig weiter – es wird NICHT auf dessen Ende gewartet. Gibt die Prozess-ID (PID) zurück. Nutze dies für lang laufende Prozesse wie Server, Watcher oder Build-Prozesse. stdout/stderr werden NICHT erfasst.", input_schema = new { type = "object", properties = new Dictionary { ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen im Hintergrund" } }, required = new[] { "command" } } }, new { name = "glob", description = "Findet Dateien im Workspace anhand eines Glob-Patterns (z.B. '**/*.cs', 'src/*.js'). Gibt eine Liste passender Dateipfade zurück.", input_schema = new { type = "object", properties = new Dictionary { ["pattern"] = new { type = "string", description = "Glob-Pattern, z.B. '**/*.cs' oder 'src/**/*.js'" }, ["path"] = new { type = "string", description = "Startverzeichnis (relativ zum Workspace, leer = Root)" } }, required = new[] { "pattern" } } }, new { name = "grep", description = "Durchsucht Dateien im Workspace nach einem Regex- oder Text-Pattern. Gibt Treffer mit Dateiname und Zeilennummer zurück.", input_schema = new { type = "object", properties = new Dictionary { ["pattern"] = new { type = "string", description = "Suchbegriff oder Regex-Pattern" }, ["path"] = new { type = "string", description = "Zu durchsuchendes Verzeichnis oder Datei (relativ, leer = Root)" }, ["glob"] = new { type = "string", description = "Dateifilter als Glob-Pattern, z.B. '*.cs' (leer = alle Dateien)" }, ["case_sensitive"] = new { type = "boolean", description = "Groß-/Kleinschreibung beachten (Default: false)" } }, required = new[] { "pattern" } } }, new { name = "todo_write", description = "Schreibt die komplette Todo-Liste. Ersetzt immer die gesamte Liste. Nutze dies um Aufgaben zu planen und den Fortschritt zu verfolgen.", input_schema = new { type = "object", properties = new Dictionary { ["todos"] = new { type = "array", description = "Liste aller Todo-Einträge", items = new { type = "object", properties = new Dictionary { ["id"] = new { type = "string", description = "Eindeutige ID (z.B. '1', '2', 'task-1')" }, ["content"] = new { type = "string", description = "Beschreibung der Aufgabe" }, ["status"] = new { type = "string", @enum = new[] { "pending", "in_progress", "completed" }, description = "Status der Aufgabe" }, ["priority"] = new { type = "string", @enum = new[] { "high", "medium", "low" }, description = "Priorität (optional)" } }, required = new[] { "id", "content", "status" } } } }, required = new[] { "todos" } } } }; } private object GetMemorySearchTool() { return new { name = "memory_search", description = "Semantische Suche über alle vergangenen Chat-Nachrichten (inklusive archivierter Sessions). Findet relevante Informationen aus früheren Gesprächen anhand von Bedeutung, nicht exakten Wörtern. Nutze dies wenn du Kontext aus vergangenen Interaktionen brauchst.", input_schema = new { type = "object", properties = new Dictionary { ["query"] = new { type = "string", description = "Suchanfrage – beschreibe wonach du suchst (z.B. 'Druckerproblem letzte Woche', 'Server-Migration Konfiguration')" }, ["top_n"] = new { type = "integer", description = "Anzahl der Ergebnisse (Default: 10, Max: 30)" } }, required = new[] { "query" } } }; } private object GetRemoteExecuteTool(string remoteOs = null) { var isLinux = (remoteOs ?? "").Contains("Linux", StringComparison.OrdinalIgnoreCase); var desc = isLinux ? "Führt einen Bash-Befehl auf der verbundenen Remote-Linux-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Netzwerk, Services, Logs, Pakete). Lange Befehle sind ok: abgebrochen wird nur, wenn der Client für längere Zeit gar keine Aktivität mehr meldet (Inaktivitäts-Timeout, konfigurierbar über ExecuteTimeout)." : "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). Lange Befehle sind ok: abgebrochen wird nur, wenn der Client für längere Zeit gar keine Aktivität mehr meldet (Inaktivitäts-Timeout, konfigurierbar über ExecuteTimeout)."; var cmdDesc = isLinux ? "Bash-Befehl zum Ausführen auf dem Remote-PC" : "PowerShell-Befehl zum Ausführen auf dem Remote-PC"; return new { name = "remote_execute", description = desc, input_schema = new { type = "object", properties = new Dictionary { ["command"] = new { type = "string", description = cmdDesc } }, required = new[] { "command" } } }; } private object[] GetMemoryToolDefinitions() { return new object[] { new { name = "memory_read", description = "Liest eine Memory-Datei des Agents. Nutze dies um gespeicherte Erinnerungen, Notizen oder Kontextinformationen abzurufen.", input_schema = new { type = "object", properties = new Dictionary { ["path"] = new { type = "string", description = "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')" } }, required = new[] { "path" } } }, new { name = "memory_write", description = "Schreibt oder überschreibt eine Memory-Datei. Nutze dies um wichtige Informationen, Entscheidungen oder Präferenzen dauerhaft zu speichern.", input_schema = new { type = "object", properties = new Dictionary { ["path"] = new { type = "string", description = "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')" }, ["content"] = new { type = "string", description = "Inhalt der Datei" }, ["append"] = new { type = "boolean", description = "Wenn true, wird der Inhalt angehängt statt überschrieben (Default: false)" } }, required = new[] { "path", "content" } } }, new { name = "memory_list", description = "Listet alle Memory-Dateien des Agents auf.", input_schema = new { type = "object", properties = new Dictionary { ["path"] = new { type = "string", description = "Optionaler Unterpfad zum Auflisten (leer = Root)" } }, required = Array.Empty() } } }; } public async Task ExecuteTool(string name, JsonElement input, ToolExecutionContext ctx) { switch (name) { case "read_file": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var path = ResolvePath(input.GetProperty("path").GetString() ?? "", ctx.Workspace); if (!File.Exists(path)) return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}"; var allLines = File.ReadAllLines(path); var totalLines = allLines.Length; var offset = input.TryGetProperty("offset", out var offsetProp) ? Math.Max(1, offsetProp.GetInt32()) : 1; var limit = input.TryGetProperty("limit", out var limitProp) ? Math.Max(1, limitProp.GetInt32()) : 2000; var startIndex = offset - 1; if (startIndex >= totalLines) return $"Fehler: offset ({offset}) ist größer als die Anzahl der Zeilen ({totalLines})."; var selectedLines = allLines.Skip(startIndex).Take(limit).ToArray(); var result = string.Join("\n", selectedLines); var endLine = startIndex + selectedLines.Length; if (endLine < totalLines) result += $"\n\n[Hinweis: Datei hat {totalLines} Zeilen, nur Zeilen {offset}–{endLine} angezeigt. Nutze offset/limit um mehr zu lesen.]"; return result; } case "write_file": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var path = ResolvePath(input.GetProperty("path").GetString() ?? "", ctx.Workspace); var dir = Path.GetDirectoryName(path); if (dir != null) Directory.CreateDirectory(dir); var content = input.GetProperty("content").GetString() ?? ""; File.WriteAllText(path, content); return $"Datei geschrieben: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)} ({content.Length} Zeichen)"; } case "edit_file": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var path = ResolvePath(input.GetProperty("path").GetString() ?? "", ctx.Workspace); if (!File.Exists(path)) return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}"; var fileContent = File.ReadAllText(path); var oldText = input.GetProperty("old_text").GetString() ?? ""; var newText = input.GetProperty("new_text").GetString() ?? ""; if (!fileContent.Contains(oldText)) return "Fehler: old_text nicht in der Datei gefunden. Exakter Match nötig."; fileContent = fileContent.Replace(oldText, newText); File.WriteAllText(path, fileContent); return $"Datei bearbeitet: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}"; } case "list_files": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var path = ResolvePath(input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "", ctx.Workspace); if (!Directory.Exists(path)) return $"Fehler: Verzeichnis nicht gefunden: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}"; var entries = new List(); foreach (var dir in Directory.GetDirectories(path)) entries.Add(WorkspaceHelper.GetRelativePath(ctx.Workspace, dir) + "/"); foreach (var file in Directory.GetFiles(path)) entries.Add(WorkspaceHelper.GetRelativePath(ctx.Workspace, file)); return entries.Count == 0 ? "(leer)" : string.Join("\n", entries); } case "delete_file": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var path = ResolvePath(input.GetProperty("path").GetString() ?? "", ctx.Workspace); if (File.Exists(path)) { File.Delete(path); return $"Datei gelöscht: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}"; } if (Directory.Exists(path)) { Directory.Delete(path, recursive: false); return $"Verzeichnis gelöscht: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}"; } return $"Fehler: Nicht gefunden: {WorkspaceHelper.GetRelativePath(ctx.Workspace, path)}"; } case "execute": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var command = input.GetProperty("command").GetString() ?? ""; // Unter Windows: Befehl in temp .cmd-Datei schreiben und mit /c ausführen. // stdin-Ansatz funktioniert nicht zuverlässig (interaktiver Modus, &-Parsing). string scriptPath = null; var psi = new ProcessStartInfo { FileName = WorkspaceHelper.ShellExecutable, WorkingDirectory = ctx.Workspace, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; if (WorkspaceHelper.IsWindows) { scriptPath = Path.Combine(Path.GetTempPath(), $"gott_exec_{Guid.NewGuid():N}.cmd"); File.WriteAllText(scriptPath, $"@echo off\r\n{command}\r\n"); psi.Arguments = $"/c \"{scriptPath}\""; } else { psi.RedirectStandardInput = true; } using var proc = Process.Start(psi); if (proc == null) { if (scriptPath != null) try { File.Delete(scriptPath); } catch { } return "Fehler: Prozess konnte nicht gestartet werden"; } if (!WorkspaceHelper.IsWindows) { await proc.StandardInput.WriteAsync(command); proc.StandardInput.Close(); } // Timeout UND externen CancellationToken verknüpfen var timeoutSeconds = ExecuteTimeoutSeconds; using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); // stdout/stderr VOR WaitForExit lesen – verhindert Deadlock // wenn Pipe-Buffer volllaufen (Windows: nur 4KB!) var stdoutTask = proc.StandardOutput.ReadToEndAsync(); var stderrTask = proc.StandardError.ReadToEndAsync(); try { await proc.WaitForExitAsync(cts.Token); } catch (OperationCanceledException) { proc.Kill(entireProcessTree: true); if (scriptPath != null) try { File.Delete(scriptPath); } catch { } // Wenn der externe Token abgebrochen wurde, weiterwerfen ctx.CancellationToken.ThrowIfCancellationRequested(); return $"Fehler: Timeout nach {timeoutSeconds} Sekunden - Prozess abgebrochen"; } if (scriptPath != null) try { File.Delete(scriptPath); } catch { } var stdout = await stdoutTask; var stderr = await stderrTask; var result = new StringBuilder(); if (!string.IsNullOrEmpty(stdout)) result.Append(stdout); if (!string.IsNullOrEmpty(stderr)) { if (result.Length > 0) result.Append("\n--- stderr ---\n"); result.Append(stderr); } if (result.Length == 0) result.Append($"(exit code {proc.ExitCode})"); else if (proc.ExitCode != 0) result.Append($"\n(exit code {proc.ExitCode})"); return result.ToString(); } case "background_execute": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var command = input.GetProperty("command").GetString() ?? ""; // Befehl in temporäre Script-Datei schreiben und losgelöst starten. // So funktionieren auch mehrzeilige Befehle zuverlässig. var scriptId = Guid.NewGuid().ToString("N")[..8]; string scriptPath; if (WorkspaceHelper.IsWindows) { scriptPath = Path.Combine(Path.GetTempPath(), $"gott_bg_{scriptId}.cmd"); // Script löscht sich nach Ausführung selbst File.WriteAllText(scriptPath, $"@echo off\r\n{command}\r\ndel \"%~f0\"\r\n"); } else { scriptPath = Path.Combine(Path.GetTempPath(), $"gott_bg_{scriptId}.sh"); File.WriteAllText(scriptPath, $"#!/bin/sh\n{command}\nrm -f \"{scriptPath}\"\n"); // Ausführbar machen var chmod = Process.Start(new ProcessStartInfo { FileName = "chmod", Arguments = $"+x \"{scriptPath}\"", UseShellExecute = false, CreateNoWindow = true }); chmod?.WaitForExit(5000); } var psi = new ProcessStartInfo { FileName = WorkspaceHelper.IsWindows ? "cmd.exe" : scriptPath, Arguments = WorkspaceHelper.IsWindows ? $"/c \"{scriptPath}\"" : "", WorkingDirectory = ctx.Workspace, UseShellExecute = false, CreateNoWindow = true }; var proc = Process.Start(psi); if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden"; return $"Hintergrund-Prozess gestartet (PID: {proc.Id})"; } case "glob": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var pattern = input.GetProperty("pattern").GetString() ?? "*"; var basePath = input.TryGetProperty("path", out var gp) ? gp.GetString() ?? "" : ""; var searchRoot = string.IsNullOrEmpty(basePath) ? ctx.Workspace : ResolvePath(basePath, ctx.Workspace); if (!Directory.Exists(searchRoot)) return $"Fehler: Verzeichnis nicht gefunden: {basePath}"; var matcher = new Matcher(); matcher.AddInclude(pattern); var dirInfo = new DirectoryInfoWrapper(new DirectoryInfo(searchRoot)); var result = matcher.Execute(dirInfo); var matches = result.Files .Select(f => WorkspaceHelper.GetRelativePath(ctx.Workspace, Path.Combine(searchRoot, f.Path))) .OrderBy(f => f) .ToList(); return matches.Count == 0 ? "(keine Treffer)" : string.Join("\n", matches); } case "grep": { if (string.IsNullOrEmpty(ctx.Workspace)) return "Fehler: Kein Workspace konfiguriert"; var pattern = input.GetProperty("pattern").GetString() ?? ""; var searchPath = input.TryGetProperty("path", out var sp) ? sp.GetString() ?? "" : ""; var fileGlob = input.TryGetProperty("glob", out var fg) ? fg.GetString() ?? "*" : "*"; var caseSensitive = input.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean(); var regexOptions = caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase; Regex regex; try { regex = new Regex(pattern, regexOptions); } catch { regex = new Regex(Regex.Escape(pattern), regexOptions); } var searchRoot = string.IsNullOrEmpty(searchPath) ? ctx.Workspace : ResolvePath(searchPath, ctx.Workspace); IEnumerable files; if (File.Exists(searchRoot)) files = new[] { searchRoot }; else if (Directory.Exists(searchRoot)) files = Directory.GetFiles(searchRoot, fileGlob, SearchOption.AllDirectories); else return $"Fehler: Pfad nicht gefunden: {searchPath}"; var results = new List(); foreach (var file in files.OrderBy(f => f)) { string[] lines; try { lines = File.ReadAllLines(file); } catch { continue; } for (var i = 0; i < lines.Length; i++) { if (regex.IsMatch(lines[i])) results.Add($"{WorkspaceHelper.GetRelativePath(ctx.Workspace, file)}:{i + 1}: {lines[i].Trim()}"); } } if (results.Count == 0) return "(keine Treffer)"; if (results.Count > 500) results = results.Take(500).Append("... (auf 500 Treffer begrenzt)").ToList(); return string.Join("\n", results); } case "todo_write": { var todos = input.GetProperty("todos"); var sb = new StringBuilder(); foreach (var todo in todos.EnumerateArray()) { var id = todo.GetProperty("id").GetString(); var content = todo.GetProperty("content").GetString(); var status = todo.GetProperty("status").GetString(); var priority = todo.TryGetProperty("priority", out var prio) ? prio.GetString() : null; var icon = status switch { "completed" => "✅", "in_progress" => "🔄", _ => "⬜" }; var prioTag = priority != null ? $" [{priority}]" : ""; sb.Append($"{icon} [{id}]{prioTag} {content}\n"); } return sb.Length == 0 ? "(leere Todo-Liste)" : sb.ToString().TrimEnd(); } case "memory_read": { if (ctx.MemoryService == null || string.IsNullOrEmpty(ctx.AgentId)) return "Fehler: Memory-Service nicht verfügbar"; var path = input.GetProperty("path").GetString() ?? ""; var content = ctx.MemoryService.ReadMemoryFile(ctx.AgentId, path); if (content == null) return $"Datei nicht gefunden: {path}"; return content; } case "memory_write": { if (ctx.MemoryService == null || string.IsNullOrEmpty(ctx.AgentId)) return "Fehler: Memory-Service nicht verfügbar"; var path = input.GetProperty("path").GetString() ?? ""; var content = input.GetProperty("content").GetString() ?? ""; var append = input.TryGetProperty("append", out var a) && a.GetBoolean(); if (append) ctx.MemoryService.AppendMemoryFile(ctx.AgentId, path, content); else ctx.MemoryService.WriteMemoryFile(ctx.AgentId, path, content); // Memory-Datei nach Schreiben embedden if (ctx.EmbeddingBg != null) { var fullText = ctx.MemoryService.ReadMemoryFile(ctx.AgentId, path); if (!string.IsNullOrEmpty(fullText)) ctx.EmbeddingBg.EnqueueMemoryFile(ctx.AgentId, path, fullText); } return $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)"; } case "memory_list": { if (ctx.MemoryService == null || string.IsNullOrEmpty(ctx.AgentId)) return "Fehler: Memory-Service nicht verfügbar"; var subPath = input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""; var files = ctx.MemoryService.ListMemoryFiles(ctx.AgentId, subPath); return files.Count == 0 ? "(keine Memory-Dateien vorhanden)" : string.Join("\n", files); } case "memory_search": { if (ctx.VectorSearch == null) return "Fehler: Semantische Suche nicht verfügbar"; var query = input.GetProperty("query").GetString() ?? ""; var topN = input.TryGetProperty("top_n", out var tn) ? tn.GetInt32() : 10; if (topN > 30) topN = 30; if (topN < 1) topN = 1; var results = await ctx.VectorSearch.SearchAsync(query, ctx.AgentId, topN); if (results.Count == 0) return "Keine relevanten Ergebnisse gefunden."; var sb = new StringBuilder(); sb.AppendLine($"Gefunden: {results.Count} relevante Einträge\n"); for (int i = 0; i < results.Count; i++) { var r = results[i]; var date = DateTime.TryParse(r.CreatedAt, out var dt) ? dt.ToString("yyyy-MM-dd HH:mm") : r.CreatedAt; sb.AppendLine($"[{i + 1}] Score: {r.Score:F2} | {r.Role} | {date} | Session: {r.SessionId}"); sb.AppendLine(r.TextPreview); sb.AppendLine(); } return sb.ToString().TrimEnd(); } case "remote_execute": { if (ctx.RemoteMgr == null || string.IsNullOrEmpty(ctx.SessionId)) return "Fehler: Keine Remote-Verbindung verfügbar"; var conn = ctx.RemoteMgr.GetBySessionId(ctx.SessionId); if (conn == null) return "Fehler: Keine Remote-Verbindung für diese Session zugeordnet"; var command = input.GetProperty("command").GetString() ?? ""; var result = await ctx.RemoteMgr.ExecuteCommand(conn.Id, command, ExecuteTimeoutSeconds, ctx.CancellationToken); var sb = new StringBuilder(); if (!string.IsNullOrEmpty(result.Output)) sb.Append(result.Output); if (!string.IsNullOrEmpty(result.Error)) { if (sb.Length > 0) sb.AppendLine(); sb.Append("[stderr] ").Append(result.Error); } if (result.ExitCode != 0) { if (sb.Length > 0) sb.AppendLine(); sb.Append($"[Exit Code: {result.ExitCode}]"); } return sb.Length > 0 ? sb.ToString() : "(keine Ausgabe)"; } case "send_to_session": { if (ctx.Db == null || ctx.ChatService == null || (ctx.AnthropicService == null && ctx.OpenAiService == null)) return "Fehler: send_to_session nicht verfügbar"; var sessionIdOrName = input.GetProperty("session_id_or_name").GetString() ?? ""; var message = input.GetProperty("message").GetString() ?? ""; if (string.IsNullOrEmpty(sessionIdOrName)) return "Fehler: session_id_or_name fehlt"; if (string.IsNullOrEmpty(message)) return "Fehler: message fehlt"; // Ziel-Session per ID oder Name suchen (instanzweit, kein User-Check) var targetSession = await ctx.Db.GetSession(sessionIdOrName); if (targetSession == null) targetSession = await ctx.Db.GetSessionByName(sessionIdOrName); if (targetSession == null) return $"Fehler: Session '{sessionIdOrName}' nicht gefunden"; var targetSessionId = targetSession.SessionId; if (targetSessionId == ctx.SessionId) return "Fehler: Kann keine Nachricht an die eigene Session senden"; // Context für Ziel-Session vorbereiten (analog zu CronjobService) var targetCtx = await ctx.ChatService.PrepareContext(targetSessionId, userId: ctx.UserId); if (targetCtx == null) return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)"; // API-Key des Ziel-Agents auflösen (nicht den Key des aufrufenden Agents nutzen) var targetApiKey = await ctx.Db.GetAgentApiKeyRecord(ctx.UserId, targetCtx.AgentId); targetCtx.ApiToken = targetApiKey?.ApiKey ?? ctx.ApiToken; // History aufbauen – User-Message NICHT in DB speichern (wie Cronjob) // Stattdessen als kontextualisierte Nachricht in die History injizieren // includeThinking=true für OpenAI-kompatible Provider → ...-Prefix // im Assistant-Text. Anthropic ignoriert das (lehnt Blöcke ohne Signatur ab). var history = await ctx.ChatService.BuildHistoryFromDb(targetSessionId, promptBlocks: targetCtx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(targetCtx.Provider)); var senderAgent = ctx.AgentId != null ? await ctx.Db.GetAgent(ctx.AgentId) : null; var senderName = senderAgent?.Name ?? ctx.AgentId ?? "Unbekannter Agent"; var senderInfo = $"[Nachricht von Agent \"{senderName}\"]: {message}"; var userContent = new List { new { type = "text", text = senderInfo } }; history.Add(new Dictionary { ["role"] = "user", ["content"] = userContent }); try { if (!string.IsNullOrEmpty(targetCtx.Provider) && ctx.OpenAiService != null) await ctx.OpenAiService.ChatNonStreaming(targetCtx, history); else await ctx.AnthropicService.ChatNonStreaming(targetCtx, history); } catch (Exception ex) { Console.WriteLine($"[send_to_session] ChatNonStreaming Fehler: {ex}"); return $"Fehler beim Senden an Session \"{targetSession.Name}\": {ex.Message}"; } await ctx.Db.UpdateSessionTimestamp(targetSessionId); // Telegram-Benachrichtigung senden falls Session einen Bot hat if (ctx.TelegramService != null) { try { var updatedSession = await ctx.Db.GetSession(targetSessionId); if (updatedSession != null && !string.IsNullOrEmpty(updatedSession.TelegramChatId)) { var recentMsgs = await ctx.Db.GetLatestActiveMessages(targetSessionId, 5); var lastMsg = recentMsgs .Where(m => m.Role == "assistant" && m.Type == "text") .LastOrDefault(); if (lastMsg != null) await ctx.TelegramService.SendNotification(targetSessionId, lastMsg.Content); } } catch (Exception ex) { Console.WriteLine($"[send_to_session] Telegram-Benachrichtigung fehlgeschlagen: {ex.Message}"); } } return $"Nachricht wurde erfolgreich an Session \"{targetSession.Name}\" gesendet. Der Agent dort hat geantwortet."; } case "github_code_search": { return await ExecuteGitHubCodeSearch(input, ctx.CancellationToken); } case "serper_web_search": { return await ExecuteSerperWebSearch(input, ctx.CancellationToken); } default: return $"Fehler: Unbekanntes Tool: {name}"; } } private string ResolvePath(string relativePath, string workspace) { var full = Path.GetFullPath(Path.Combine(workspace, relativePath ?? "")); if (!WorkspaceHelper.IsSubPathOf(full, workspace)) throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt"); return full; } // ── GitHub Code Search ── private object GetGitHubCodeSearchTool() { return new { name = "github_code_search", description = "Durchsucht öffentliche GitHub-Repositories nach Code. Nutzt die GitHub Code Search API. Gibt Dateinamen, Repositories und Code-Ausschnitte zurück. Unterstützt GitHub-Suchsyntax (z.B. language:python, path:*.js, org:microsoft).", input_schema = new { type = "object", properties = new Dictionary { ["query"] = new { type = "string", description = "Suchbegriff mit optionaler GitHub-Suchsyntax (z.B. 'useState language:typescript', 'OPENAI_API_KEY path:.env')" }, ["per_page"] = new { type = "integer", description = "Anzahl Ergebnisse (1-50, Default: 10)" } }, required = new[] { "query" } } }; } private async Task ExecuteGitHubCodeSearch(JsonElement input, CancellationToken ct) { var token = _settings.Get("GitHubToken"); if (string.IsNullOrWhiteSpace(token)) return "Fehler: Kein GitHubToken konfiguriert. Bitte in den Server-Einstellungen hinterlegen."; var query = input.GetProperty("query").GetString() ?? ""; if (string.IsNullOrWhiteSpace(query)) return "Fehler: Suchbegriff (query) darf nicht leer sein."; var perPage = input.TryGetProperty("per_page", out var ppProp) ? Math.Clamp(ppProp.GetInt32(), 1, 50) : 10; try { var client = _httpFactory.CreateClient(); client.DefaultRequestHeaders.UserAgent.ParseAdd("GottApp/1.0"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); client.DefaultRequestHeaders.Accept.Clear(); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.text-match+json")); var url = $"https://api.github.com/search/code?q={Uri.EscapeDataString(query)}&per_page={perPage}"; var response = await client.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); return $"GitHub API Fehler {(int)response.StatusCode}: {errorBody}"; } var json = await response.Content.ReadAsStringAsync(ct); var doc = JsonDocument.Parse(json); var root = doc.RootElement; var totalCount = root.GetProperty("total_count").GetInt32(); var items = root.GetProperty("items"); var sb = new StringBuilder(); sb.AppendLine($"**{totalCount} Treffer gefunden** (zeige {Math.Min(perPage, items.GetArrayLength())})"); sb.AppendLine(); var index = 0; foreach (var item in items.EnumerateArray()) { index++; var name = item.GetProperty("name").GetString() ?? ""; var path = item.GetProperty("path").GetString() ?? ""; var repo = item.GetProperty("repository").GetProperty("full_name").GetString() ?? ""; var htmlUrl = item.GetProperty("html_url").GetString() ?? ""; sb.AppendLine($"### {index}. {repo}"); sb.AppendLine($"📄 `{path}`"); sb.AppendLine($"🔗 {htmlUrl}"); // Text-Match-Fragmente (wenn vorhanden) if (item.TryGetProperty("text_matches", out var textMatches)) { foreach (var match in textMatches.EnumerateArray()) { if (match.TryGetProperty("fragment", out var fragment)) { var frag = fragment.GetString() ?? ""; if (frag.Length > 500) frag = frag.Substring(0, 500) + "..."; sb.AppendLine("```"); sb.AppendLine(frag); sb.AppendLine("```"); } } } sb.AppendLine(); } return sb.ToString(); } catch (TaskCanceledException) { return "GitHub-Suche abgebrochen (Timeout)."; } catch (Exception ex) { return $"GitHub-Suche fehlgeschlagen: {ex.Message}"; } } // ── Serper Web Search ── private object GetSerperWebSearchTool() { return new { name = "serper_web_search", description = "Durchsucht das Internet via Google (Serper.dev API). Gibt strukturierte Suchergebnisse zurück mit Titel, Link und Snippet. Unterstützt alle Google-Suchoperatoren (z.B. exakte Phrasen mit Anführungszeichen, site:, filetype:). Nutze dies für Webrecherche, aktuelle Informationen oder das Finden spezifischer Inhalte im Internet.", input_schema = new { type = "object", properties = new Dictionary { ["query"] = new { type = "string", description = "Suchbegriff (unterstützt Google-Suchsyntax, z.B. '\"exakte Phrase\"', 'site:github.com', 'filetype:pdf')" }, ["num"] = new { type = "integer", description = "Anzahl Ergebnisse (1-100, Default: 10)" } }, required = new[] { "query" } } }; } private async Task ExecuteSerperWebSearch(JsonElement input, CancellationToken ct) { var apiKey = _settings.Get("SerperApiKey"); if (string.IsNullOrWhiteSpace(apiKey)) return "Fehler: Kein SerperApiKey konfiguriert. Bitte in den Server-Einstellungen hinterlegen."; var query = input.GetProperty("query").GetString() ?? ""; if (string.IsNullOrWhiteSpace(query)) return "Fehler: Suchbegriff (query) darf nicht leer sein."; var num = input.TryGetProperty("num", out var numProp) ? Math.Clamp(numProp.GetInt32(), 1, 100) : 10; try { var client = _httpFactory.CreateClient(); client.DefaultRequestHeaders.Add("X-API-KEY", apiKey); var requestBody = JsonSerializer.Serialize(new { q = query, num = num }); var content = new StringContent(requestBody, Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://google.serper.dev/search", content, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); return $"Serper API Fehler {(int)response.StatusCode}: {errorBody}"; } var json = await response.Content.ReadAsStringAsync(ct); var doc = JsonDocument.Parse(json); var root = doc.RootElement; var sb = new StringBuilder(); // Knowledge Graph (wenn vorhanden) if (root.TryGetProperty("knowledgeGraph", out var kg)) { if (kg.TryGetProperty("title", out var kgTitle)) sb.AppendLine($"## {kgTitle.GetString()}"); if (kg.TryGetProperty("description", out var kgDesc)) sb.AppendLine(kgDesc.GetString()); sb.AppendLine(); } // Answer Box (wenn vorhanden) if (root.TryGetProperty("answerBox", out var ab)) { sb.AppendLine("### Direkte Antwort"); if (ab.TryGetProperty("title", out var abTitle)) sb.AppendLine($"**{abTitle.GetString()}**"); if (ab.TryGetProperty("answer", out var abAnswer)) sb.AppendLine(abAnswer.GetString()); else if (ab.TryGetProperty("snippet", out var abSnippet)) sb.AppendLine(abSnippet.GetString()); sb.AppendLine(); } // Organische Ergebnisse if (root.TryGetProperty("organic", out var organic)) { var results = organic.EnumerateArray().ToArray(); sb.AppendLine($"**{results.Length} Ergebnisse:**"); sb.AppendLine(); var index = 0; foreach (var item in results) { index++; var title = item.TryGetProperty("title", out var t) ? t.GetString() : ""; var link = item.TryGetProperty("link", out var l) ? l.GetString() : ""; var snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() : ""; sb.AppendLine($"### {index}. {title}"); sb.AppendLine($"🔗 {link}"); if (!string.IsNullOrEmpty(snippet)) sb.AppendLine(snippet); sb.AppendLine(); } } if (sb.Length == 0) sb.AppendLine("Keine Ergebnisse gefunden."); return sb.ToString(); } catch (TaskCanceledException) { return "Serper-Suche abgebrochen (Timeout)."; } catch (Exception ex) { return $"Serper-Suche fehlgeschlagen: {ex.Message}"; } } }