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

+9 -4
View File
@@ -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));
+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 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>();
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<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)
{
var verhaltensRegeln = new List<string>
+98 -2
View File
@@ -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<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)
{
@@ -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}";
}