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,135 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Zentrale Stelle für den Zugang zur Instanz-Datenbank.
|
||||
///
|
||||
/// Die frühere Fassung öffnete pro Aufruf eine Verbindung ohne weitere Einstellungen —
|
||||
/// ohne WAL und ohne Wartezeit bei Sperren. Sobald mehrere Agenten gleichzeitig
|
||||
/// schreiben, quittiert SQLite das mit "database is locked". Das sah nach einer Grenze
|
||||
/// von SQLite aus, war aber nur fehlende Konfiguration.
|
||||
///
|
||||
/// - WAL erlaubt beliebig viele Leser parallel zu einem Schreiber.
|
||||
/// - busy_timeout lässt einen Schreiber kurz warten, statt sofort zu scheitern.
|
||||
/// - Connection-Pooling vermeidet den Aufbau je Aufruf.
|
||||
/// </summary>
|
||||
public sealed class SqliteStorage
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
|
||||
public string DatabasePath { get; }
|
||||
|
||||
public SqliteStorage(string databasePath)
|
||||
{
|
||||
DatabasePath = databasePath;
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
_connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = databasePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
Pooling = true,
|
||||
DefaultTimeout = 30
|
||||
}.ToString();
|
||||
|
||||
Initialize();
|
||||
}
|
||||
|
||||
public SqliteConnection OpenConnection()
|
||||
{
|
||||
var connection = new SqliteConnection(_connectionString);
|
||||
connection.Open();
|
||||
ApplyPragmas(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken ct)
|
||||
{
|
||||
var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
ApplyPragmas(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialisiert Schreibvorgänge im Prozess. SQLite lässt ohnehin nur einen Schreiber
|
||||
/// zu — die Warteschlange hier ist verlässlicher als das Zurückweisen durch die
|
||||
/// Datenbank und macht Fehlerbilder reproduzierbar.
|
||||
/// </summary>
|
||||
public async Task<T> WriteAsync<T>(Func<SqliteConnection, Task<T>> action, CancellationToken ct)
|
||||
{
|
||||
await _writeGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await using var connection = await OpenConnectionAsync(ct);
|
||||
return await action(connection);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WriteAsync(Func<SqliteConnection, Task> action, CancellationToken ct)
|
||||
=> await WriteAsync<object?>(async conn => { await action(conn); return null; }, ct);
|
||||
|
||||
private static void ApplyPragmas(SqliteConnection connection)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using var connection = OpenConnection();
|
||||
using var cmd = connection.CreateCommand();
|
||||
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS ToolState (
|
||||
Key TEXT PRIMARY KEY,
|
||||
Value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Memories (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
Scope TEXT NOT NULL,
|
||||
OwnerId TEXT NOT NULL,
|
||||
Category TEXT NOT NULL,
|
||||
Subject TEXT NOT NULL,
|
||||
MemoryKey TEXT NULL,
|
||||
Content TEXT NOT NULL,
|
||||
Tags TEXT NOT NULL DEFAULT '',
|
||||
Importance INTEGER NOT NULL DEFAULT 3,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL,
|
||||
CreatedBy TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Memories_Lookup
|
||||
ON Memories (Scope, OwnerId, Subject);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Memories_Recent
|
||||
ON Memories (Scope, OwnerId, UpdatedAt DESC);
|
||||
|
||||
-- Ein Schluessel identifiziert eine Erinnerung eindeutig; erneutes Merken
|
||||
-- unter demselben Schluessel aktualisiert sie, statt eine zweite anzulegen.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS UX_Memories_Key
|
||||
ON Memories (Scope, OwnerId, MemoryKey)
|
||||
WHERE MemoryKey IS NOT NULL;
|
||||
""";
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user