From d913557173261e7b297164529c6666eec632ae19 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sun, 1 Mar 2026 13:27:08 +0100 Subject: [PATCH] feat: Memory-System mit persistenten Markdown-Dateien pro Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemoryService: Verwaltet data/agents/{agentId}/ Verzeichnisse - MEMORY.md als Haupt-Memory-File - memory/ Unterordner für Tages-Logs und thematische Notizen - Sandbox-Prüfung verhindert Zugriff außerhalb des Agent-Dirs - Memory-Tools für den Agent: - memory_read: Liest Memory-Dateien - memory_write: Schreibt/überschreibt oder hängt an (append) - memory_list: Listet alle Memory-Dateien auf - SystemPromptBuilder: Memory-Abschnitt mit Regeln und aktuellem MEMORY.md-Inhalt wird in den System-Prompt injiziert - ToolService/AnthropicService: Memory-Tools werden automatisch eingebunden wenn MemoryService und AgentId verfügbar sind - TODO.md: Embedding-basierte Suche und Subagents als Features --- Endpoints/ChatEndpoint.cs | 8 +-- Endpoints/SessionEndpoint.cs | 4 +- Program.cs | 1 + Services/AnthropicService.cs | 13 ++-- Services/MemoryService.cs | 104 ++++++++++++++++++++++++++++++++ Services/SystemPromptBuilder.cs | 31 +++++++++- Services/ToolService.cs | 100 +++++++++++++++++++++++++++++- TODO.md | 3 +- 8 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 Services/MemoryService.cs diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 5b8f6e3..ec7e81a 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -23,7 +23,7 @@ public static class ChatEndpoint app.MapPost("/api/chat", HandleChat); } - private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db, StreamingTracker streamingTracker, CancellationToken cancellationToken) + private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db, StreamingTracker streamingTracker, MemoryService memoryService, CancellationToken cancellationToken) { var body = await JsonSerializer.DeserializeAsync(req.Body); if (body == null || body.Messages == null || body.Messages.Count == 0) @@ -78,7 +78,7 @@ public static class ChatEndpoint res.Headers["Connection"] = "keep-alive"; var markedMessages = await db.GetMarkedMessages(sessionId); - var systemPromptText = SystemPromptBuilder.Build(agent, session, markedMessages); + var systemPromptText = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService); var chainId = Guid.NewGuid().ToString("N"); @@ -155,11 +155,11 @@ public static class ChatEndpoint var maxIterations = config.GetValue("MaxIterations", 100); var toolChoice = agent.ToolChoice ?? "any"; - var anthropic = new AnthropicService(httpFactory, token, toolService, db); + var anthropic = new AnthropicService(httpFactory, token, toolService, db, memoryService); streamingTracker.Start(sessionId, chainId); try { - await anthropic.StreamChat(res, modelId, systemPrompt, messages, chainId, workspace, sessionId, maxIterations: maxIterations, toolChoice: toolChoice, cancellationToken: cancellationToken); + await anthropic.StreamChat(res, modelId, systemPrompt, messages, chainId, workspace, sessionId, agentId: agent.AgentId, maxIterations: maxIterations, toolChoice: toolChoice, cancellationToken: cancellationToken); } finally { diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index a083117..8663942 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -91,7 +91,7 @@ public static class SessionEndpoint ctx.Response.StatusCode = 204; } - private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db) + private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db, MemoryService memoryService) { var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString(); if (string.IsNullOrEmpty(sessionId)) @@ -123,7 +123,7 @@ public static class SessionEndpoint } var markedMessages = await db.GetMarkedMessages(sessionId); - var systemPrompt = SystemPromptBuilder.Build(agent, session, markedMessages); + var systemPrompt = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService); ctx.Response.ContentType = "text/plain; charset=utf-8"; await ctx.Response.WriteAsync(systemPrompt); diff --git a/Program.cs b/Program.cs index 112b7d7..1b2638e 100644 --- a/Program.cs +++ b/Program.cs @@ -13,6 +13,7 @@ var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); builder.Services.AddSingleton(new ToolService()); builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new StreamingTracker()); +builder.Services.AddSingleton(new MemoryService(dataDir)); builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db"))); var app = builder.Build(); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 8a6ce51..3438e3b 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -18,13 +18,15 @@ public class AnthropicService private readonly string _token; private readonly ToolService _toolService; private readonly DatabaseService _db; + private readonly MemoryService _memoryService; - public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db) + public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null) { _httpFactory = httpFactory; _token = token; _toolService = toolService; _db = db; + _memoryService = memoryService; } public async Task StreamChat( @@ -35,6 +37,7 @@ public class AnthropicService string chainId, string workspace, string sessionId = null, + string agentId = null, int maxIterations = 100, string toolChoice = "any", CancellationToken cancellationToken = default) @@ -69,7 +72,8 @@ public class AnthropicService if (!string.IsNullOrEmpty(workspace)) { - payload["tools"] = _toolService.GetToolDefinitions(); + var includeMemory = _memoryService != null && !string.IsNullOrEmpty(agentId); + payload["tools"] = _toolService.GetToolDefinitions(includeMemory); // "any" erzwingt Tool-Nutzung, aber nur in der ersten Iteration, // damit Claude nicht endlos Tools aufruft wenn die Aufgabe erledigt ist. var effectiveChoice = (toolChoice == "any" && iterations == 1) ? "any" : "auto"; @@ -102,7 +106,7 @@ public class AnthropicService return; } - var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage, workspace, cancellationToken); + var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage, workspace, agentId, cancellationToken); lastStopReason = stopReason; messages.Add(new { role = "assistant", content = assistantContent }); @@ -234,6 +238,7 @@ public class AnthropicService HttpResponseMessage httpResp, TokenUsage usage, string workspace, + string agentId = null, CancellationToken cancellationToken = default) { var stream = await httpResp.Content.ReadAsStreamAsync(cancellationToken); @@ -356,7 +361,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace); } + try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId); } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } executedTools.Add(new ToolResult(currentToolId, toolResult, isError)); diff --git a/Services/MemoryService.cs b/Services/MemoryService.cs new file mode 100644 index 0000000..3430af0 --- /dev/null +++ b/Services/MemoryService.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Gott.Services; + +public class MemoryService +{ + private readonly string _dataDir; + + public MemoryService(string dataDir) + { + _dataDir = dataDir; + } + + /// + /// Gibt den Memory-Ordner für einen Agent zurück. Erstellt ihn bei Bedarf. + /// + public string GetAgentMemoryDir(string agentId) + { + var dir = Path.Combine(_dataDir, "agents", agentId); + Directory.CreateDirectory(dir); + return dir; + } + + /// + /// Liest eine Memory-Datei. Gibt null zurück wenn nicht vorhanden. + /// + public string ReadMemoryFile(string agentId, string fileName) + { + var path = ResolvePath(agentId, fileName); + if (path == null || !File.Exists(path)) return null; + return File.ReadAllText(path); + } + + /// + /// Schreibt/überschreibt eine Memory-Datei. + /// + public void WriteMemoryFile(string agentId, string fileName, string content) + { + var path = ResolvePath(agentId, fileName); + if (path == null) throw new Exception("Ungültiger Dateipfad"); + var dir = Path.GetDirectoryName(path); + if (dir != null) Directory.CreateDirectory(dir); + File.WriteAllText(path, content); + } + + /// + /// Hängt Inhalt an eine Memory-Datei an. + /// + public void AppendMemoryFile(string agentId, string fileName, string content) + { + var path = ResolvePath(agentId, fileName); + if (path == null) throw new Exception("Ungültiger Dateipfad"); + var dir = Path.GetDirectoryName(path); + if (dir != null) Directory.CreateDirectory(dir); + File.AppendAllText(path, content); + } + + /// + /// Listet alle Memory-Dateien eines Agents auf. + /// + public List ListMemoryFiles(string agentId, string subPath = null) + { + var baseDir = GetAgentMemoryDir(agentId); + var targetDir = string.IsNullOrEmpty(subPath) + ? baseDir + : Path.GetFullPath(Path.Combine(baseDir, subPath)); + + if (!targetDir.StartsWith(baseDir)) + throw new Exception("Zugriff außerhalb des Memory-Verzeichnisses nicht erlaubt"); + + if (!Directory.Exists(targetDir)) + return new List(); + + var entries = new List(); + foreach (var dir in Directory.GetDirectories(targetDir)) + entries.Add(Path.GetRelativePath(baseDir, dir) + "/"); + foreach (var file in Directory.GetFiles(targetDir)) + entries.Add(Path.GetRelativePath(baseDir, file)); + return entries; + } + + /// + /// Liest MEMORY.md (das Haupt-Memory-File). Gibt leeren String zurück wenn nicht vorhanden. + /// + public string GetMainMemory(string agentId) + { + return ReadMemoryFile(agentId, "MEMORY.md") ?? ""; + } + + /// + /// Löst einen Pfad relativ zum Agent-Memory-Dir auf und prüft Sandbox. + /// + private string ResolvePath(string agentId, string fileName) + { + var baseDir = GetAgentMemoryDir(agentId); + var full = Path.GetFullPath(Path.Combine(baseDir, fileName)); + if (!full.StartsWith(baseDir)) + return null; + return full; + } +} diff --git a/Services/SystemPromptBuilder.cs b/Services/SystemPromptBuilder.cs index d00b107..d3b6197 100644 --- a/Services/SystemPromptBuilder.cs +++ b/Services/SystemPromptBuilder.cs @@ -6,7 +6,7 @@ namespace Gott.Services; public static class SystemPromptBuilder { - public static string Build(AgentRecord agent, SessionRecord session, IEnumerable markedMessages = null) + public static string Build(AgentRecord agent, SessionRecord session, IEnumerable markedMessages = null, MemoryService memoryService = null) { var parts = new List(); parts.Add($"Du bist {agent.Name}."); @@ -39,6 +39,35 @@ Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: fo if (!string.IsNullOrWhiteSpace(session.Context)) parts.Add($"## Kontext\n{session.Context}"); + // Memory-System + if (memoryService != null && !string.IsNullOrWhiteSpace(agent.AgentId)) + { + var memoryLines = new List + { + "## Memory", + "Du hast ein persistentes Gedächtnis über Memory-Tools (`memory_read`, `memory_write`, `memory_list`).", + $"Dein Memory-Verzeichnis: `data/agents/{agent.AgentId}/`", + "", + "### Regeln", + "- Nutze `memory_read` um gespeicherte Erinnerungen abzurufen, bevor du über vergangene Entscheidungen, Präferenzen oder Fakten sprichst.", + "- Nutze `memory_write` um wichtige Informationen dauerhaft zu speichern – z.B. Entscheidungen, Präferenzen des Users, Projekt-Kontext.", + "- `MEMORY.md` ist dein Haupt-Memory-File für kuratierte, dauerhafte Informationen.", + "- Nutze Unterordner `memory/` für Tages-Logs oder thematische Notizen (z.B. `memory/2025-07-13.md`).", + "- Speichere proaktiv wenn der User etwas Wichtiges sagt oder eine Entscheidung getroffen wird.", + "- Wenn der User sagt \"merke dir das\" oder ähnliches, speichere es sofort." + }; + + var mainMemory = memoryService.GetMainMemory(agent.AgentId); + if (!string.IsNullOrEmpty(mainMemory)) + { + memoryLines.Add(""); + memoryLines.Add("### Aktueller Inhalt von MEMORY.md"); + memoryLines.Add(mainMemory); + } + + parts.Add(string.Join("\n", memoryLines)); + } + if (toolAny) { var verhaltensRegeln = new List diff --git a/Services/ToolService.cs b/Services/ToolService.cs index d0324a3..f785047 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -11,7 +11,21 @@ namespace Gott.Services; public class ToolService { - public object[] GetToolDefinitions() + public object[] GetToolDefinitions(bool includeMemory = false) + { + var tools = GetFileTools(); + if (includeMemory) + { + var memTools = GetMemoryToolDefinitions(); + var all = new object[tools.Length + memTools.Length]; + tools.CopyTo(all, 0); + memTools.CopyTo(all, tools.Length); + return all; + } + return tools; + } + + private object[] GetFileTools() { return new object[] { @@ -105,7 +119,58 @@ public class ToolService }; } - public async Task ExecuteTool(string name, JsonElement input, string workspace) + 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/2025-01-15.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/2025-01-15.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) { switch (name) { @@ -206,6 +271,37 @@ public class ToolService else if (proc.ExitCode != 0) result.Append($"\n(exit code {proc.ExitCode})"); return result.ToString(); } + 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); + 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); + } default: return $"Fehler: Unbekanntes Tool: {name}"; } diff --git a/TODO.md b/TODO.md index 6944936..60cc50b 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,8 @@ ## Features - [ ] Skills: In Datenbank anlegen können, mit Pfad zu Skill-Dateien; im Frontend am Dashboard dargestellt. Zuweisung an Agent und/oder Session. - [ ] System-Prompt: Nachrichten einer Session als Referenz und somit in den System-Prompt aufnehmen +- [ ] Memory: Embedding-Modell für semantische Suche über Memory-Dateien (Vektor-basiert + BM25 Hybrid Search) +- [ ] Subagents: Möglichkeit Subagents zu starten und zu orchestrieren ## Bugs - [x] Wenn gerade gestreamt wird und man refreshed die Seite, dann verliert das Frontend den Status "Streaming..." und ich kann eine Nachricht schreiben, obwohl das nicht möglich sein sollte @@ -17,4 +19,3 @@ - [x] Chat: Wird zwar erkannt und ist klickbar, aber es öffnet sich einfach nur der File Explorer, ohne dass sich die Datei öffnet und die Darstellung in Monospace ist auch nicht schön ## Backlog -- [ ] Gibt es eine Möglichkeit Subagents zu starten?