feat: Memory-System mit persistenten Markdown-Dateien pro Agent

- 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
This commit is contained in:
Meleki committed 2026-03-01 13:27:08 +01:00
1 parent 875229964c
commit d913557173
8 files changed
+250 -14

No files matched your search

+4 -4
View File
@@ -23,7 +23,7 @@ public static class ChatEndpoint
app.MapPost("/api/chat", HandleChat); 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<ChatRequest>(req.Body); var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null || body.Messages == null || body.Messages.Count == 0) if (body == null || body.Messages == null || body.Messages.Count == 0)
@@ -78,7 +78,7 @@ public static class ChatEndpoint
res.Headers["Connection"] = "keep-alive"; res.Headers["Connection"] = "keep-alive";
var markedMessages = await db.GetMarkedMessages(sessionId); 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"); var chainId = Guid.NewGuid().ToString("N");
@@ -155,11 +155,11 @@ public static class ChatEndpoint
var maxIterations = config.GetValue<int>("MaxIterations", 100); var maxIterations = config.GetValue<int>("MaxIterations", 100);
var toolChoice = agent.ToolChoice ?? "any"; 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); streamingTracker.Start(sessionId, chainId);
try 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 finally
{ {
+2 -2
View File
@@ -91,7 +91,7 @@ public static class SessionEndpoint
ctx.Response.StatusCode = 204; 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(); var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId)) if (string.IsNullOrEmpty(sessionId))
@@ -123,7 +123,7 @@ public static class SessionEndpoint
} }
var markedMessages = await db.GetMarkedMessages(sessionId); 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"; ctx.Response.ContentType = "text/plain; charset=utf-8";
await ctx.Response.WriteAsync(systemPrompt); await ctx.Response.WriteAsync(systemPrompt);
+1
View File
@@ -13,6 +13,7 @@ var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
builder.Services.AddSingleton(new ToolService()); builder.Services.AddSingleton(new ToolService());
builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new ImageService());
builder.Services.AddSingleton(new StreamingTracker()); builder.Services.AddSingleton(new StreamingTracker());
builder.Services.AddSingleton(new MemoryService(dataDir));
builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db"))); builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db")));
var app = builder.Build(); var app = builder.Build();
+9 -4
View File
@@ -18,13 +18,15 @@ public class AnthropicService
private readonly string _token; private readonly string _token;
private readonly ToolService _toolService; private readonly ToolService _toolService;
private readonly DatabaseService _db; 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; _httpFactory = httpFactory;
_token = token; _token = token;
_toolService = toolService; _toolService = toolService;
_db = db; _db = db;
_memoryService = memoryService;
} }
public async Task StreamChat( public async Task StreamChat(
@@ -35,6 +37,7 @@ public class AnthropicService
string chainId, string chainId,
string workspace, string workspace,
string sessionId = null, string sessionId = null,
string agentId = null,
int maxIterations = 100, int maxIterations = 100,
string toolChoice = "any", string toolChoice = "any",
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -69,7 +72,8 @@ public class AnthropicService
if (!string.IsNullOrEmpty(workspace)) 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, // "any" erzwingt Tool-Nutzung, aber nur in der ersten Iteration,
// damit Claude nicht endlos Tools aufruft wenn die Aufgabe erledigt ist. // damit Claude nicht endlos Tools aufruft wenn die Aufgabe erledigt ist.
var effectiveChoice = (toolChoice == "any" && iterations == 1) ? "any" : "auto"; var effectiveChoice = (toolChoice == "any" && iterations == 1) ? "any" : "auto";
@@ -102,7 +106,7 @@ public class AnthropicService
return; 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; lastStopReason = stopReason;
messages.Add(new { role = "assistant", content = assistantContent }); messages.Add(new { role = "assistant", content = assistantContent });
@@ -234,6 +238,7 @@ public class AnthropicService
HttpResponseMessage httpResp, HttpResponseMessage httpResp,
TokenUsage usage, TokenUsage usage,
string workspace, string workspace,
string agentId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var stream = await httpResp.Content.ReadAsStreamAsync(cancellationToken); var stream = await httpResp.Content.ReadAsStreamAsync(cancellationToken);
@@ -356,7 +361,7 @@ public class AnthropicService
string toolResult; string toolResult;
bool isError = false; 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; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
executedTools.Add(new ToolResult(currentToolId, toolResult, isError)); executedTools.Add(new ToolResult(currentToolId, toolResult, isError));
+104
View File
@@ -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;
}
/// <summary>
/// Gibt den Memory-Ordner für einen Agent zurück. Erstellt ihn bei Bedarf.
/// </summary>
public string GetAgentMemoryDir(string agentId)
{
var dir = Path.Combine(_dataDir, "agents", agentId);
Directory.CreateDirectory(dir);
return dir;
}
/// <summary>
/// Liest eine Memory-Datei. Gibt null zurück wenn nicht vorhanden.
/// </summary>
public string ReadMemoryFile(string agentId, string fileName)
{
var path = ResolvePath(agentId, fileName);
if (path == null || !File.Exists(path)) return null;
return File.ReadAllText(path);
}
/// <summary>
/// Schreibt/überschreibt eine Memory-Datei.
/// </summary>
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);
}
/// <summary>
/// Hängt Inhalt an eine Memory-Datei an.
/// </summary>
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);
}
/// <summary>
/// Listet alle Memory-Dateien eines Agents auf.
/// </summary>
public List<string> 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<string>();
var entries = new List<string>();
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;
}
/// <summary>
/// Liest MEMORY.md (das Haupt-Memory-File). Gibt leeren String zurück wenn nicht vorhanden.
/// </summary>
public string GetMainMemory(string agentId)
{
return ReadMemoryFile(agentId, "MEMORY.md") ?? "";
}
/// <summary>
/// Löst einen Pfad relativ zum Agent-Memory-Dir auf und prüft Sandbox.
/// </summary>
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;
}
}
+30 -1
View File
@@ -6,7 +6,7 @@ namespace Gott.Services;
public static class SystemPromptBuilder public static class SystemPromptBuilder
{ {
public static string Build(AgentRecord agent, SessionRecord session, IEnumerable<MessageRecord> markedMessages = null) public static string Build(AgentRecord agent, SessionRecord session, IEnumerable<MessageRecord> markedMessages = null, MemoryService memoryService = null)
{ {
var parts = new List<string>(); var parts = new List<string>();
parts.Add($"Du bist {agent.Name}."); 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)) if (!string.IsNullOrWhiteSpace(session.Context))
parts.Add($"## Kontext\n{session.Context}"); parts.Add($"## Kontext\n{session.Context}");
// Memory-System
if (memoryService != null && !string.IsNullOrWhiteSpace(agent.AgentId))
{
var memoryLines = new List<string>
{
"## 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) if (toolAny)
{ {
var verhaltensRegeln = new List<string> var verhaltensRegeln = new List<string>
+98 -2
View File
@@ -11,7 +11,21 @@ namespace Gott.Services;
public class ToolService 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[] return new object[]
{ {
@@ -105,7 +119,58 @@ public class ToolService
}; };
} }
public async Task<string> 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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["path"] = new { type = "string", description = "Optionaler Unterpfad zum Auflisten (leer = Root)" }
},
required = Array.Empty<string>()
}
}
};
}
public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null)
{ {
switch (name) switch (name)
{ {
@@ -206,6 +271,37 @@ public class ToolService
else if (proc.ExitCode != 0) result.Append($"\n(exit code {proc.ExitCode})"); else if (proc.ExitCode != 0) result.Append($"\n(exit code {proc.ExitCode})");
return result.ToString(); 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: default:
return $"Fehler: Unbekanntes Tool: {name}"; return $"Fehler: Unbekanntes Tool: {name}";
} }
+2 -1
View File
@@ -3,6 +3,8 @@
## Features ## Features
- [ ] Skills: In Datenbank anlegen können, mit Pfad zu Skill-Dateien; im Frontend am Dashboard dargestellt. Zuweisung an Agent und/oder Session. - [ ] 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 - [ ] 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 ## 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 - [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 - [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 ## Backlog
- [ ] Gibt es eine Möglichkeit Subagents zu starten?