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:
Richard
2026-07-28 10:44:39 +02:00
co-authored by Claude Opus 4.8
parent 8604fa30c7
commit 4747835fa1
13 changed files with 1425 additions and 51 deletions
@@ -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");
}
}