K1: Langzeitgedaechtnis fuer Agenten
Geplante Agenten begannen bei jedem Cron-Lauf bei null. Ein Agent, der alle 30 Minuten lief, wusste nichts von seinem letzten Durchgang — er rief dieselben Quellen ab, zog dieselben Schluesse und konnte keine Entwicklung ueber Zeit verfolgen. Das war zugleich die groesste Faehigkeitsluecke und eine dauerhafte Token-Verschwendung. Speicher-Fundament SqliteStorage buendelt den Zugang zur Instanz-Datenbank und aktiviert WAL, busy_timeout und Connection-Pooling. Vorher oeffnete jeder Aufruf eine Verbindung ohne diese Einstellungen; bei mehreren gleichzeitig schreibenden Agenten gab das "database is locked". Das sah nach einer Grenze von SQLite aus, war aber nur fehlende Konfiguration. Zwei Tests decken das gezielt ab. Gedaechtnis Typisierte Tabelle statt JSON in einer Wert-Spalte — nur so laesst sich filtern, sortieren und spaeter auswerten. Das Schema ist schlicht gehalten, damit eine MySQL-Variante spaeter dieselbe Struktur mit wenigen Dialektunterschieden bekommen kann. Der wichtigste Teil ist der optionale Schluessel: Erneutes Merken darunter aktualisiert den Eintrag, statt einen zweiten anzulegen. Ohne das wuechse das Gedaechtnis eines halbstuendlich laufenden Agenten um 48 Eintraege pro Tag zur selben Sache. Beobachtungen ohne Schluessel sammeln sich weiterhin an, wenn ein Verlauf entstehen soll. Der Abruf sortiert nach Wichtigkeit, dann Aktualitaet — wesentlich, weil das Ergebnis begrenzt wird und bei einer Kappung das Wichtigste ueberleben muss. Zusaetzlich greift eine Zeichenobergrenze, damit ein Abruf den Kontext nicht sprengt. Die Trennung privat/geteilt ist absichtlich dieselbe wie beim FileRW-Tool, damit das Konzept fuer Agenten wiedererkennbar bleibt. Beim Testen fiel auf, dass das Maskieren der LIKE-Platzhalter falsch war: Die Zeichen wurden entfernt statt maskiert, wodurch eine Suche nach einem Prozentzeichen zu einem leeren Muster und damit zu einem Treffer auf alles wurde. Jetzt mit ESCAPE-Klausel. 338 Tests gruen (190 Core, 148 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8604fa30c7
commit
4747835fa1
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.Memory</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Memory;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Langzeitgedächtnis für Agenten.
|
||||
///
|
||||
/// Ohne dieses Tool beginnt ein geplanter Agent bei jedem Cron-Lauf bei null: Er ruft
|
||||
/// dieselben Quellen ab, zieht dieselben Schlüsse und kann keine Entwicklung über die
|
||||
/// Zeit verfolgen. Das ist zugleich die größte Fähigkeitslücke und eine dauerhafte
|
||||
/// Token-Verschwendung.
|
||||
/// </summary>
|
||||
public sealed class MemoryTool : IAgentTool
|
||||
{
|
||||
/// <summary>Obergrenze für die Ausgabe, damit ein Abruf den Kontext nicht sprengt.</summary>
|
||||
private const int MaxResultChars = 8_000;
|
||||
|
||||
public string Name => "Memory";
|
||||
|
||||
public string Description => """
|
||||
Dein Langzeitgedächtnis — überdauert einzelne Läufe und Neustarts.
|
||||
|
||||
Nutze es, um Erkenntnisse festzuhalten, die beim nächsten Lauf noch zählen:
|
||||
getroffene Entscheidungen, gesicherte Fakten, offene Punkte, Beobachtungen
|
||||
über Zeit. Prüfe zu Beginn eines Laufs mit 'recall', was du bereits weißt,
|
||||
statt es erneut herzuleiten.
|
||||
|
||||
Aktionen: remember, recall, forget, list_subjects
|
||||
|
||||
Zwei Bereiche:
|
||||
- scope='agent' (Standard): nur für dich sichtbar
|
||||
- scope='shared': für alle Agenten der Instanz sichtbar
|
||||
|
||||
Zum 'key': Vergib einen, wenn eine Angabe sich später ändern kann
|
||||
(z.B. key='kursziel_nvda'). Erneutes Merken unter demselben Schlüssel
|
||||
aktualisiert den Eintrag, statt einen zweiten anzulegen — so wächst dein
|
||||
Gedächtnis nicht zu.
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["remember", "recall", "forget", "list_subjects"],
|
||||
"description": "Die auszuführende Aktion."
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["agent", "shared"],
|
||||
"description": "Bereich. Standard ist 'agent' (nur für dich)."
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Worum es geht — z.B. 'NVDA', 'Kunde Meier', 'Projekt Alpha'. Pflicht bei remember."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Was du dir merken willst. Pflicht bei remember."
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Optionaler eindeutiger Schlüssel. Erneutes Merken darunter aktualisiert den Eintrag."
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["fact", "decision", "observation", "task", "contact", "other"],
|
||||
"description": "Art der Erinnerung. Standard 'observation'."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Schlagworte zum späteren Wiederfinden."
|
||||
},
|
||||
"importance": {
|
||||
"type": "integer",
|
||||
"description": "1 (nebensächlich) bis 5 (zentral). Standard 3. Steuert die Reihenfolge beim Abruf."
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Suchtext für recall — wird gegen Betreff, Inhalt und Schlagworte geprüft."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximale Anzahl Treffer bei recall. Standard 20."
|
||||
},
|
||||
"minImportance": {
|
||||
"type": "integer",
|
||||
"description": "Nur Erinnerungen ab dieser Wichtigkeit abrufen."
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id der zu löschenden Erinnerung (forget)."
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (context.Memory is not { } repository)
|
||||
return ToolResult.Fail("Das Gedächtnis ist für diese Instanz nicht verfügbar.");
|
||||
|
||||
var action = input.TryGetProperty("action", out var a) ? a.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
return ToolResult.Fail("'action' ist erforderlich.");
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"remember" => await RememberAsync(input, repository, context, ct),
|
||||
"recall" => await RecallAsync(input, repository, context, ct),
|
||||
"forget" => await ForgetAsync(input, repository, context, ct),
|
||||
"list_subjects" => await ListSubjectsAsync(input, repository, context, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler im Memory-Tool bei Aktion {Action}", action);
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Aktionen ───
|
||||
|
||||
private static async Task<ToolResult> RememberAsync(
|
||||
JsonElement input, IMemoryRepository repository, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var subject = GetString(input, "subject");
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return ToolResult.Fail("'subject' ist erforderlich — worum geht es?");
|
||||
|
||||
var content = GetString(input, "content");
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Fail("'content' ist erforderlich — was soll gemerkt werden?");
|
||||
|
||||
var scope = GetScope(input);
|
||||
|
||||
var entry = new MemoryEntry
|
||||
{
|
||||
Scope = scope,
|
||||
OwnerId = context.AgentId,
|
||||
Subject = subject.Trim(),
|
||||
Content = content.Trim(),
|
||||
Key = GetString(input, "key"),
|
||||
Category = MemoryCategory.Normalize(GetString(input, "category") ?? MemoryCategory.Observation),
|
||||
Tags = GetTags(input),
|
||||
Importance = GetInt(input, "importance") ?? 3,
|
||||
CreatedBy = context.AgentId
|
||||
};
|
||||
|
||||
var saved = await repository.RememberAsync(entry, ct);
|
||||
|
||||
var scopeLabel = scope == MemoryScope.Shared ? "geteilt" : "privat";
|
||||
var keyHint = string.IsNullOrWhiteSpace(saved.Key) ? "" : $" | Schlüssel: {saved.Key}";
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Gemerkt (#{saved.Id}, {scopeLabel}){keyHint}\n" +
|
||||
$"Betreff: {saved.Subject} | Art: {saved.Category} | Wichtigkeit: {saved.Importance}");
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> RecallAsync(
|
||||
JsonElement input, IMemoryRepository repository, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var scope = GetScope(input);
|
||||
|
||||
var query = new MemoryQuery
|
||||
{
|
||||
Scope = scope,
|
||||
OwnerId = context.AgentId,
|
||||
Search = GetString(input, "query"),
|
||||
Subject = GetString(input, "subject"),
|
||||
Category = GetString(input, "category"),
|
||||
Tags = GetTags(input),
|
||||
Limit = GetInt(input, "limit") ?? 20,
|
||||
MinImportance = GetInt(input, "minImportance") ?? 1
|
||||
};
|
||||
|
||||
var entries = await repository.RecallAsync(query, ct);
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
var total = await repository.CountAsync(scope, context.AgentId, ct);
|
||||
return ToolResult.Ok(total == 0
|
||||
? "Noch keine Erinnerungen in diesem Bereich."
|
||||
: $"Keine Treffer. Der Bereich enthält {total} Erinnerung(en) — " +
|
||||
"versuche einen anderen Suchbegriff oder 'list_subjects' für einen Überblick.");
|
||||
}
|
||||
|
||||
return ToolResult.Ok(Render(entries, scope));
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> ForgetAsync(
|
||||
JsonElement input, IMemoryRepository repository, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (GetInt(input, "id") is { } id)
|
||||
{
|
||||
var removed = await repository.ForgetAsync(id, ct);
|
||||
return ToolResult.Ok(removed
|
||||
? $"Erinnerung #{id} gelöscht."
|
||||
: $"Keine Erinnerung mit der Id {id} gefunden.");
|
||||
}
|
||||
|
||||
var subject = GetString(input, "subject");
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return ToolResult.Fail("Für 'forget' wird entweder 'id' oder 'subject' benötigt.");
|
||||
|
||||
var count = await repository.ForgetBySubjectAsync(GetScope(input), context.AgentId, subject, ct);
|
||||
return ToolResult.Ok($"{count} Erinnerung(en) zum Betreff '{subject}' gelöscht.");
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> ListSubjectsAsync(
|
||||
JsonElement input, IMemoryRepository repository, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var scope = GetScope(input);
|
||||
var subjects = await repository.ListSubjectsAsync(
|
||||
scope, context.AgentId, GetInt(input, "limit") ?? 50, ct);
|
||||
|
||||
if (subjects.Count == 0)
|
||||
return ToolResult.Ok("Noch keine Erinnerungen in diesem Bereich.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Betreffs im Bereich '{(scope == MemoryScope.Shared ? "shared" : "agent")}':");
|
||||
foreach (var (subject, count) in subjects)
|
||||
sb.AppendLine($" • {subject} ({count})");
|
||||
|
||||
return ToolResult.Ok(sb.ToString().TrimEnd());
|
||||
}
|
||||
|
||||
// ─── Darstellung ───
|
||||
|
||||
private static string Render(IReadOnlyList<MemoryEntry> entries, MemoryScope scope)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"{entries.Count} Erinnerung(en), wichtigste zuerst:");
|
||||
sb.AppendLine();
|
||||
|
||||
var shown = 0;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var block = FormatEntry(entry, scope);
|
||||
|
||||
// Lieber weniger Treffer als ein gesprengter Kontext.
|
||||
if (sb.Length + block.Length > MaxResultChars)
|
||||
{
|
||||
sb.AppendLine($"[… {entries.Count - shown} weitere Treffer ausgelassen. " +
|
||||
"Grenze die Suche ein, um sie zu sehen.]");
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append(block);
|
||||
shown++;
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string FormatEntry(MemoryEntry entry, MemoryScope scope)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append($"#{entry.Id} [{entry.Subject}] {entry.Category}, Wichtigkeit {entry.Importance}");
|
||||
if (!string.IsNullOrWhiteSpace(entry.Key))
|
||||
sb.Append($", Schlüssel: {entry.Key}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine($" {entry.Content}");
|
||||
|
||||
var meta = new List<string> { $"aktualisiert {entry.UpdatedAt:yyyy-MM-dd HH:mm} UTC" };
|
||||
if (entry.Tags.Count > 0)
|
||||
meta.Add("Schlagworte: " + string.Join(", ", entry.Tags));
|
||||
if (scope == MemoryScope.Shared && !string.IsNullOrWhiteSpace(entry.CreatedBy))
|
||||
meta.Add($"von {entry.CreatedBy}");
|
||||
|
||||
sb.AppendLine($" ({string.Join(" | ", meta)})");
|
||||
sb.AppendLine();
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Eingabe lesen ───
|
||||
|
||||
private static string? GetString(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
private static int? GetInt(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
? value.GetInt32()
|
||||
: null;
|
||||
|
||||
private static MemoryScope GetScope(JsonElement input)
|
||||
=> string.Equals(GetString(input, "scope"), "shared", StringComparison.OrdinalIgnoreCase)
|
||||
? MemoryScope.Shared
|
||||
: MemoryScope.Agent;
|
||||
|
||||
private static IReadOnlyList<string> GetTags(JsonElement input)
|
||||
{
|
||||
if (!input.TryGetProperty("tags", out var tags) || tags.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
return tags.EnumerateArray()
|
||||
.Where(t => t.ValueKind == JsonValueKind.String)
|
||||
.Select(t => t.GetString()!)
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user