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,417 @@
|
||||
using ClawdDotNet.Core.Memory;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// K1 aus der Bestandsaufnahme: Geplante Agenten begannen bei jedem Cron-Lauf bei null.
|
||||
///
|
||||
/// Getestet wird gegen eine echte SQLite-Datei, nicht gegen eine Attrappe — nur so
|
||||
/// werden Schema, Indizes, Sperrverhalten und die Behandlung gleichzeitiger Zugriffe
|
||||
/// tatsächlich geprüft.
|
||||
/// </summary>
|
||||
public sealed class MemoryRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteMemoryRepository _repository;
|
||||
|
||||
private const string AgentA = "agent-a";
|
||||
private const string AgentB = "agent-b";
|
||||
|
||||
public MemoryRepositoryTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_repository = new SqliteMemoryRepository(_storage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private Task<MemoryEntry> Remember(
|
||||
string subject, string content, string owner = AgentA,
|
||||
MemoryScope scope = MemoryScope.Agent, string? key = null,
|
||||
int importance = 3, string? category = null, params string[] tags)
|
||||
=> _repository.RememberAsync(new MemoryEntry
|
||||
{
|
||||
Scope = scope,
|
||||
OwnerId = owner,
|
||||
Subject = subject,
|
||||
Content = content,
|
||||
Key = key,
|
||||
Importance = importance,
|
||||
Category = category ?? MemoryCategory.Observation,
|
||||
Tags = tags,
|
||||
CreatedBy = owner
|
||||
}, default);
|
||||
|
||||
private Task<IReadOnlyList<MemoryEntry>> Recall(
|
||||
string? search = null, string owner = AgentA, MemoryScope scope = MemoryScope.Agent,
|
||||
string? subject = null, int limit = 20, int minImportance = 1, params string[] tags)
|
||||
=> _repository.RecallAsync(new MemoryQuery
|
||||
{
|
||||
Scope = scope,
|
||||
OwnerId = owner,
|
||||
Search = search,
|
||||
Subject = subject,
|
||||
Tags = tags,
|
||||
Limit = limit,
|
||||
MinImportance = minImportance
|
||||
}, default);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Grundfunktionen
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Erinnerung_ueberdauert_und_wird_wiedergefunden()
|
||||
{
|
||||
await Remember("NVDA", "Quartalszahlen am 20.11. erwartet");
|
||||
|
||||
var found = await Recall("Quartalszahlen");
|
||||
|
||||
found.Count.ShouldBe(1);
|
||||
found[0].Subject.ShouldBe("NVDA");
|
||||
found[0].Content.ShouldContain("20.11.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Suche_greift_auf_Betreff_Inhalt_und_Schlagworte()
|
||||
{
|
||||
await Remember("NVDA", "Kursziel angehoben", tags: ["analyse", "quartal"]);
|
||||
|
||||
(await Recall("NVDA")).Count.ShouldBe(1);
|
||||
(await Recall("Kursziel")).Count.ShouldBe(1);
|
||||
(await Recall("analyse")).Count.ShouldBe(1);
|
||||
(await Recall("voellig anderes")).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Suche_beachtet_Gross_und_Kleinschreibung_nicht()
|
||||
{
|
||||
await Remember("NVDA", "Kursziel angehoben");
|
||||
|
||||
(await Recall("kursZIEL")).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_Betreff_laesst_sich_gezielt_filtern()
|
||||
{
|
||||
await Remember("NVDA", "Erste Notiz");
|
||||
await Remember("TSLA", "Zweite Notiz");
|
||||
|
||||
var found = await Recall(subject: "NVDA");
|
||||
|
||||
found.Count.ShouldBe(1);
|
||||
found[0].Content.ShouldBe("Erste Notiz");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Schlüssel: aktualisieren statt zuwachsen
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Schluessel_aktualisiert_statt_eine_zweite_Erinnerung_anzulegen()
|
||||
{
|
||||
// Sonst wüchse das Gedächtnis bei einem alle 30 Minuten laufenden Agenten
|
||||
// binnen eines Tages um 48 Einträge zur selben Sache.
|
||||
await Remember("NVDA", "Kursziel 120", key: "kursziel_nvda");
|
||||
await Remember("NVDA", "Kursziel 145", key: "kursziel_nvda");
|
||||
|
||||
var found = await Recall(subject: "NVDA");
|
||||
|
||||
found.Count.ShouldBe(1);
|
||||
found[0].Content.ShouldBe("Kursziel 145");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Beim_Aktualisieren_bleibt_das_Anlagedatum_erhalten()
|
||||
{
|
||||
var first = await Remember("NVDA", "Erste Fassung", key: "k");
|
||||
await Task.Delay(10);
|
||||
var second = await Remember("NVDA", "Zweite Fassung", key: "k");
|
||||
|
||||
second.Id.ShouldBe(first.Id);
|
||||
second.CreatedAt.ShouldBe(first.CreatedAt);
|
||||
second.UpdatedAt.ShouldBeGreaterThan(first.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Schluessel_entstehen_mehrere_Eintraege()
|
||||
{
|
||||
await Remember("NVDA", "Beobachtung eins");
|
||||
await Remember("NVDA", "Beobachtung zwei");
|
||||
|
||||
(await Recall(subject: "NVDA")).Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Derselbe_Schluessel_bei_verschiedenen_Agenten_kollidiert_nicht()
|
||||
{
|
||||
await Remember("NVDA", "Sicht von A", owner: AgentA, key: "einschaetzung");
|
||||
await Remember("NVDA", "Sicht von B", owner: AgentB, key: "einschaetzung");
|
||||
|
||||
(await Recall(owner: AgentA))[0].Content.ShouldBe("Sicht von A");
|
||||
(await Recall(owner: AgentB))[0].Content.ShouldBe("Sicht von B");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Bereiche
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Private_Erinnerungen_sind_fuer_andere_Agenten_unsichtbar()
|
||||
{
|
||||
await Remember("Geheim", "Nur für A", owner: AgentA);
|
||||
|
||||
(await Recall(owner: AgentB)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Geteilte_Erinnerungen_sehen_alle_Agenten()
|
||||
{
|
||||
await Remember("Team", "Gemeinsame Erkenntnis", owner: AgentA, scope: MemoryScope.Shared);
|
||||
|
||||
var fromB = await Recall(owner: AgentB, scope: MemoryScope.Shared);
|
||||
|
||||
fromB.Count.ShouldBe(1);
|
||||
fromB[0].CreatedBy.ShouldBe(AgentA, "auch im geteilten Bereich bleibt die Herkunft sichtbar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Geteilte_und_private_Bereiche_sind_getrennt()
|
||||
{
|
||||
await Remember("X", "privat", owner: AgentA);
|
||||
await Remember("X", "geteilt", owner: AgentA, scope: MemoryScope.Shared);
|
||||
|
||||
(await Recall(owner: AgentA)).Count.ShouldBe(1);
|
||||
(await Recall(owner: AgentA, scope: MemoryScope.Shared)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Reihenfolge und Kappung
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Wichtiges_kommt_zuerst()
|
||||
{
|
||||
// Entscheidend, weil der Abruf begrenzt wird — das Wichtigste muss überleben.
|
||||
await Remember("A", "nebensächlich", importance: 1);
|
||||
await Remember("B", "zentral", importance: 5);
|
||||
await Remember("C", "mittel", importance: 3);
|
||||
|
||||
var found = await Recall();
|
||||
|
||||
found[0].Content.ShouldBe("zentral");
|
||||
found[^1].Content.ShouldBe("nebensächlich");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bei_gleicher_Wichtigkeit_kommt_das_Aktuellste_zuerst()
|
||||
{
|
||||
await Remember("A", "aelter", importance: 3);
|
||||
await Task.Delay(10);
|
||||
await Remember("B", "neuer", importance: 3);
|
||||
|
||||
(await Recall())[0].Content.ShouldBe("neuer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unwichtiges_laesst_sich_ausblenden()
|
||||
{
|
||||
await Remember("A", "nebensächlich", importance: 1);
|
||||
await Remember("B", "zentral", importance: 5);
|
||||
|
||||
var found = await Recall(minImportance: 4);
|
||||
|
||||
found.Count.ShouldBe(1);
|
||||
found[0].Content.ShouldBe("zentral");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Trefferzahl_ist_begrenzt()
|
||||
{
|
||||
for (var i = 0; i < 50; i++)
|
||||
await Remember("Massen", $"Notiz {i}");
|
||||
|
||||
(await Recall(limit: 5)).Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Wichtigkeit_bleibt_im_gueltigen_Bereich()
|
||||
{
|
||||
var tooHigh = await Remember("A", "x", importance: 99);
|
||||
var tooLow = await Remember("B", "y", importance: -5);
|
||||
|
||||
tooHigh.Importance.ShouldBe(5);
|
||||
tooLow.Importance.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Schlagworte
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_Schlagworten_laesst_sich_filtern()
|
||||
{
|
||||
await Remember("A", "eins", tags: ["dringend", "kunde"]);
|
||||
await Remember("B", "zwei", tags: ["kunde"]);
|
||||
await Remember("C", "drei");
|
||||
|
||||
(await Recall(tags: "kunde")).Count.ShouldBe(2);
|
||||
(await Recall(tags: "dringend")).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Schlagwort_trifft_kein_laengeres_mit_gleichem_Anfang()
|
||||
{
|
||||
// "news" darf nicht "newsletter" treffen.
|
||||
await Remember("A", "eins", tags: ["newsletter"]);
|
||||
|
||||
(await Recall(tags: "news")).ShouldBeEmpty();
|
||||
(await Recall(tags: "newsletter")).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Schlagworte_ueberstehen_den_Rundlauf()
|
||||
{
|
||||
await Remember("A", "eins", tags: ["Alpha", "Beta"]);
|
||||
|
||||
var found = await Recall();
|
||||
|
||||
found[0].Tags.ShouldBe(["alpha", "beta"]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Löschen und Überblick
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Erinnerung_laesst_sich_loeschen()
|
||||
{
|
||||
var entry = await Remember("A", "vergänglich");
|
||||
|
||||
(await _repository.ForgetAsync(entry.Id, default)).ShouldBeTrue();
|
||||
(await Recall()).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Das_Loeschen_einer_unbekannten_Id_meldet_das()
|
||||
{
|
||||
(await _repository.ForgetAsync(99_999, default)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Alle_Erinnerungen_zu_einem_Betreff_lassen_sich_loeschen()
|
||||
{
|
||||
await Remember("NVDA", "eins");
|
||||
await Remember("NVDA", "zwei");
|
||||
await Remember("TSLA", "drei");
|
||||
|
||||
var removed = await _repository.ForgetBySubjectAsync(MemoryScope.Agent, AgentA, "NVDA", default);
|
||||
|
||||
removed.ShouldBe(2);
|
||||
(await Recall()).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Ueberblick_zeigt_Betreffs_mit_Anzahl()
|
||||
{
|
||||
await Remember("NVDA", "eins");
|
||||
await Remember("NVDA", "zwei");
|
||||
await Remember("TSLA", "drei");
|
||||
|
||||
var subjects = await _repository.ListSubjectsAsync(MemoryScope.Agent, AgentA, 10, default);
|
||||
|
||||
subjects.Count.ShouldBe(2);
|
||||
subjects[0].ShouldBe(("NVDA", 2));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Nebenläufigkeit — der eigentliche Grund für WAL und busy_timeout
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Viele_Agenten_koennen_gleichzeitig_schreiben()
|
||||
{
|
||||
// Ohne WAL und Sperr-Wartezeit quittiert SQLite das mit "database is locked".
|
||||
var tasks = Enumerable.Range(0, 60)
|
||||
.Select(i => Remember($"Betreff {i % 5}", $"Notiz {i}", owner: $"agent-{i % 6}"));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
var total = 0;
|
||||
for (var i = 0; i < 6; i++)
|
||||
total += await _repository.CountAsync(MemoryScope.Agent, $"agent-{i}", default);
|
||||
|
||||
total.ShouldBe(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gleichzeitiges_Schreiben_und_Lesen_stoert_sich_nicht()
|
||||
{
|
||||
await Remember("Basis", "vorhanden");
|
||||
|
||||
var writes = Enumerable.Range(0, 20).Select(i => Remember("Neu", $"Notiz {i}"));
|
||||
var reads = Enumerable.Range(0, 20).Select(_ => Recall());
|
||||
|
||||
await Task.WhenAll(writes.Cast<Task>().Concat(reads));
|
||||
|
||||
(await _repository.CountAsync(MemoryScope.Agent, AgentA, default)).ShouldBe(21);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Sonderfälle
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Prozentzeichen_im_Suchtext_wirkt_nicht_als_Platzhalter()
|
||||
{
|
||||
await Remember("A", "Erster Eintrag");
|
||||
await Remember("B", "Zweiter Eintrag");
|
||||
|
||||
// Als LIKE-Muster würde "%" alles treffen.
|
||||
(await Recall("%")).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Umlaute_und_Emoji_ueberstehen_die_Ablage()
|
||||
{
|
||||
await Remember("Größe", "Prüfung mit Umlauten und 🦀");
|
||||
|
||||
var found = await Recall("Prüfung");
|
||||
|
||||
found[0].Content.ShouldBe("Prüfung mit Umlauten und 🦀");
|
||||
found[0].Subject.ShouldBe("Größe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_unbekannte_Kategorie_faellt_auf_other_zurueck()
|
||||
{
|
||||
var entry = await Remember("A", "x", category: "phantasie");
|
||||
|
||||
entry.Category.ShouldBe(MemoryCategory.Other);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Erinnerungen_ueberdauern_das_Schliessen_der_Datenbank()
|
||||
{
|
||||
// Der eigentliche Zweck: Ein Neustart der Anwendung darf nichts kosten.
|
||||
await Remember("Dauerhaft", "muss einen Neustart überleben");
|
||||
|
||||
var wiederEroeffnet = new SqliteMemoryRepository(
|
||||
new SqliteStorage(Path.Combine(_directory, "state.db")));
|
||||
|
||||
var found = await wiederEroeffnet.RecallAsync(
|
||||
new MemoryQuery { Scope = MemoryScope.Agent, OwnerId = AgentA }, default);
|
||||
|
||||
found.Count.ShouldBe(1);
|
||||
found[0].Content.ShouldBe("muss einen Neustart überleben");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user