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:
1 parent
875229964c
commit
d913557173
8 files changed
+250
-14
No files matched your search
@@ -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<ChatRequest>(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<int>("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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}";
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
Reference in new issue
Block a user