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
+28 -44
View File
@@ -1,63 +1,47 @@
using ClawdDotNet.Core.Storage;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.State;
/// <summary>
/// Schlüssel-Wert-Ablage für kleine Tool-Zustände (zuletzt gesehene IDs, Zeitstempel).
/// Nutzt das gemeinsame Speicher-Fundament, damit WAL und Sperr-Wartezeiten greifen.
/// </summary>
public sealed class SqliteStateStore : IStateStore
{
private readonly string _connectionString;
private readonly SqliteStorage _storage;
public SqliteStateStore(string dbPath)
{
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadWriteCreate
}.ToString();
public SqliteStateStore(SqliteStorage storage) => _storage = storage;
InitializeDatabase();
}
private void InitializeDatabase()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS ToolState (Key TEXT PRIMARY KEY, Value TEXT)";
cmd.ExecuteNonQuery();
}
public SqliteStateStore(string dbPath) : this(new SqliteStorage(dbPath)) { }
public async Task<string?> GetAsync(string key, CancellationToken ct)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = @key";
cmd.Parameters.AddWithValue("@key", key);
var result = await cmd.ExecuteScalarAsync(ct);
return result?.ToString();
return result is DBNull or null ? null : result.ToString();
}
public async Task SetAsync(string key, string value, CancellationToken ct)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES (@key, @value)";
cmd.Parameters.AddWithValue("@key", key);
cmd.Parameters.AddWithValue("@value", value);
await cmd.ExecuteNonQueryAsync(ct);
}
public Task SetAsync(string key, string value, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES (@key, @value)";
cmd.Parameters.AddWithValue("@key", key);
cmd.Parameters.AddWithValue("@value", value);
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task DeleteAsync(string key, CancellationToken ct)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM ToolState WHERE Key = @key";
cmd.Parameters.AddWithValue("@key", key);
await cmd.ExecuteNonQueryAsync(ct);
}
public Task DeleteAsync(string key, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM ToolState WHERE Key = @key";
cmd.Parameters.AddWithValue("@key", key);
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
}