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 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(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false, bool includeSendToSession = false, bool includeGitHubSearch = false, bool includeSerperSearch = false)
{
var allTools = new List();
if (includeUserChoice)
allTools.Add(GetAskUserChoiceTool());
if (includeFileTools)
allTools.AddRange(GetFileTools());
if (includeMemory)
allTools.AddRange(GetMemoryToolDefinitions());
if (includeMemorySearch)
allTools.Add(GetMemorySearchTool());
if (includeRemote)
allTools.Add(GetRemoteExecuteTool());
if (includeSendToSession)
allTools.Add(GetSendToSessionTool());
if (includeGitHubSearch)
allTools.Add(GetGitHubCodeSearchTool());
if (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()
{
return new
{
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.",
input_schema = new
{
type = "object",
properties = new Dictionary
{
["command"] = new { type = "string", description = "PowerShell-Befehl zum Ausführen auf dem Remote-PC" }
},
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, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, OpenAiService openAiService = null, string userId = null, TelegramService telegramService = null, string apiToken = null)
{
switch (name)
{
case "read_file":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
if (!File.Exists(path))
return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(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(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", 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(workspace, path)} ({content.Length} Zeichen)";
}
case "edit_file":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
if (!File.Exists(path))
return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(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(workspace, path)}";
}
case "list_files":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "", workspace);
if (!Directory.Exists(path))
return $"Fehler: Verzeichnis nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
var entries = new List();
foreach (var dir in Directory.GetDirectories(path))
entries.Add(WorkspaceHelper.GetRelativePath(workspace, dir) + "/");
foreach (var file in Directory.GetFiles(path))
entries.Add(WorkspaceHelper.GetRelativePath(workspace, file));
return entries.Count == 0 ? "(leer)" : string.Join("\n", entries);
}
case "delete_file":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
if (File.Exists(path))
{
File.Delete(path);
return $"Datei gelöscht: {WorkspaceHelper.GetRelativePath(workspace, path)}";
}
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: false);
return $"Verzeichnis gelöscht: {WorkspaceHelper.GetRelativePath(workspace, path)}";
}
return $"Fehler: Nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
}
case "execute":
{
if (string.IsNullOrEmpty(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 = 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(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
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(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 = 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(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)
? workspace
: ResolvePath(basePath, 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(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(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)
? workspace
: ResolvePath(searchPath, 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(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 (memoryService == null || string.IsNullOrEmpty(agentId))
return "Fehler: Memory-Service nicht verfügbar";
var path = input.GetProperty("path").GetString() ?? "";
var content = memoryService.ReadMemoryFile(agentId, path);
if (content == null)
return $"Datei nicht gefunden: {path}";
return content;
}
case "memory_write":
{
if (memoryService == null || string.IsNullOrEmpty(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)
memoryService.AppendMemoryFile(agentId, path, content);
else
memoryService.WriteMemoryFile(agentId, path, content);
// Memory-Datei nach Schreiben embedden
if (embeddingBg != null)
{
var fullText = memoryService.ReadMemoryFile(agentId, path);
if (!string.IsNullOrEmpty(fullText))
embeddingBg.EnqueueMemoryFile(agentId, path, fullText);
}
return $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)";
}
case "memory_list":
{
if (memoryService == null || string.IsNullOrEmpty(agentId))
return "Fehler: Memory-Service nicht verfügbar";
var subPath = input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var files = memoryService.ListMemoryFiles(agentId, subPath);
return files.Count == 0 ? "(keine Memory-Dateien vorhanden)" : string.Join("\n", files);
}
case "memory_search":
{
if (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 vectorSearch.SearchAsync(query, 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 (remoteMgr == null || string.IsNullOrEmpty(sessionId))
return "Fehler: Keine Remote-Verbindung verfügbar";
var conn = remoteMgr.GetBySessionId(sessionId);
if (conn == null)
return "Fehler: Keine Remote-Verbindung für diese Session zugeordnet";
var command = input.GetProperty("command").GetString() ?? "";
var result = await remoteMgr.ExecuteCommand(conn.Id, command, ExecuteTimeoutSeconds, 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 (db == null || chatService == null || (anthropicService == null && 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 db.GetSession(sessionIdOrName);
if (targetSession == null)
targetSession = await db.GetSessionByName(sessionIdOrName);
if (targetSession == null)
return $"Fehler: Session '{sessionIdOrName}' nicht gefunden";
var targetSessionId = targetSession.SessionId;
if (targetSessionId == sessionId)
return "Fehler: Kann keine Nachricht an die eigene Session senden";
// Context für Ziel-Session vorbereiten (analog zu CronjobService)
var ctx = await chatService.PrepareContext(targetSessionId, userId: userId);
if (ctx == 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 db.GetAgentApiKeyRecord(userId, ctx.AgentId);
ctx.ApiToken = targetApiKey?.ApiKey ?? apiToken;
// History aufbauen – User-Message NICHT in DB speichern (wie Cronjob)
// Stattdessen als kontextualisierte Nachricht in die History injizieren
var history = await chatService.BuildHistoryFromDb(targetSessionId, promptBlocks: ctx.PromptBlocks);
var senderAgent = agentId != null ? await db.GetAgent(agentId) : null;
var senderName = senderAgent?.Name ?? 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(ctx.Provider) && openAiService != null)
await openAiService.ChatNonStreaming(ctx, history);
else
await anthropicService.ChatNonStreaming(ctx, history);
}
catch (Exception ex)
{
Console.WriteLine($"[send_to_session] ChatNonStreaming Fehler: {ex}");
return $"Fehler beim Senden an Session \"{targetSession.Name}\": {ex.Message}";
}
await db.UpdateSessionTimestamp(targetSessionId);
// Telegram-Benachrichtigung senden falls Session einen Bot hat
if (telegramService != null)
{
try
{
var updatedSession = await db.GetSession(targetSessionId);
if (updatedSession != null && !string.IsNullOrEmpty(updatedSession.TelegramChatId))
{
var recentMsgs = await db.GetLatestActiveMessages(targetSessionId, 5);
var lastMsg = recentMsgs
.Where(m => m.Role == "assistant" && m.Type == "text")
.LastOrDefault();
if (lastMsg != null)
await 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, cancellationToken);
}
case "serper_web_search":
{
return await ExecuteSerperWebSearch(input, 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}";
}
}
}