diff --git a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationRecord.kt b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationRecord.kt new file mode 100644 index 0000000..902b00c --- /dev/null +++ b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationRecord.kt @@ -0,0 +1,14 @@ +package pw.binom.agentik.journal + +import kotlin.time.Instant + +/** + * Snapshot диалога. В таблице `conversation` хранится как есть. + */ +data class ConversationRecord( + val id: String, + val title: String?, + val isTemporal: Boolean, + val createdAt: Instant, + val updatedAt: Instant, +) diff --git a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationStore.kt b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationStore.kt new file mode 100644 index 0000000..f16ec26 --- /dev/null +++ b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationStore.kt @@ -0,0 +1,27 @@ +package pw.binom.agentik.journal + +import kotlin.time.Instant + +/** + * CRUD по таблице `conversation`. + */ +interface ConversationStore : AutoCloseable { + + /** Создать или обновить snapshot диалога. */ + suspend fun upsert(record: ConversationRecord) + + /** Диалог по id, или `null`. */ + suspend fun get(id: String): ConversationRecord? + + /** Удалить диалог (вместе с его сообщениями и working memory). */ + suspend fun delete(id: String): Boolean + + /** Список диалогов, отсортированный по `updatedAt` DESC. */ + suspend fun list(offset: Int, limit: Int): List + + /** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */ + suspend fun rename(id: String, title: String?): Instant? + + /** Обновить `updatedAt` диалога (например, после отправки сообщения). */ + suspend fun touch(id: String, now: Instant) +} diff --git a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/Ids.kt b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/Ids.kt new file mode 100644 index 0000000..febb160 --- /dev/null +++ b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/Ids.kt @@ -0,0 +1,14 @@ +package pw.binom.agentik.journal + +import kotlin.uuid.Uuid + +/** + * Генератор id. Использует `kotlin.uuid.Uuid` из stdlib (KMP: jvm + native), + * чтобы не зависеть от `java.util.UUID` и подготовить код к linuxX64-сборке. + * + * Сохраняет формат `-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` — + * `:server` его парсит как opaque string, без знания внутренней структуры. + */ +object Ids { + fun new(prefix: String): String = "$prefix-${Uuid.random()}" +} diff --git a/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/MemoryVectorIndex.kt b/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/MemoryVectorIndex.kt new file mode 100644 index 0000000..43ceee2 --- /dev/null +++ b/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/MemoryVectorIndex.kt @@ -0,0 +1,58 @@ +package pw.binom.agentik.memory + +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryNote + +/** + * Результат одного hit'а vector-поиска: id заметки + cosine-similarity score + * в [0..1]. Чем ближе к 1.0, тем семантически ближе query к заметке. + * + * Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), + * переехал в `:memory-api` 2026-09-21 чтобы быть доступным из + * `:memory-md-vector` (KMP linuxX64/mingwX64), который больше не зависит + * от JVM-only `:memory-vector`. + */ +data class ScoredVector( + val id: String, + val score: Float, +) + +/** + * Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших + * векторов к query. Метаданные заметок лежат в `MemoryStore` (для + * vector-бэкенда — отдельный `MemoryMetaStore` в `:memory-vector`); + * индекс хранит только embedding'и + id-маппинг. + * + * Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), + * переехал в `:memory-api` 2026-09-21 чтобы быть доступным из + * `:memory-md-vector` (KMP). + * + * Потокобезопасность: реализации обязаны быть безопасны для конкурентных + * read'ов. write'ы (add/remove) могут требовать внешней синхронизации — + * это инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций). + */ +interface MemoryVectorIndex : AutoCloseable { + /** Текущая размерность embeddings. Фиксируется при первом [add]. */ + val dimension: Int + + /** Количество записей в индексе. */ + suspend fun size(): Long + + /** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */ + suspend fun add(id: String, embedding: FloatArray) + + /** Удалить запись по [id]. Возвращает true если запись была. */ + suspend fun remove(id: String): Boolean + + /** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id. */ + suspend fun search( + query: FloatArray, + k: Int, + filter: (MemoryNote) -> Boolean = { true }, + ): List + + /** Принудительно переписать on-disk файл из текущего in-RAM состояния. */ + suspend fun flush() + + override fun close() +} diff --git a/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/NoteMatches.kt b/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/NoteMatches.kt new file mode 100644 index 0000000..28e3ce8 --- /dev/null +++ b/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/NoteMatches.kt @@ -0,0 +1,22 @@ +package pw.binom.agentik.memory + +/** + * Доп. контекст для vector-индекса: фильтр по категории и conversationId. + * + * Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), но с + * переездом `:memory-md-vector` на KMP (linuxX64/mingwX64 и др.) он перенесён + * сюда — `:memory-md-vector` больше не зависит от JVM-only `:memory-vector`. + * + * Реализация `MemoryStore` (и `:memory-md`, и `:memory-vector`, и любые + * будущие) должны использовать этот хелпер при фильтрации результатов search, + * чтобы контракт был единый. + */ +fun noteMatches( + note: MemoryNote, + category: MemoryCategory? = null, + conversationId: String? = null, +): Boolean { + if (category != null && note.category != category) return false + if (conversationId != null && note.conversationId != conversationId) return false + return true +} diff --git a/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/TextEmbeddingExecutor.kt b/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/TextEmbeddingExecutor.kt new file mode 100644 index 0000000..db874b6 --- /dev/null +++ b/memory-api/src/commonMain/kotlin/pw/binom/agentik/memory/TextEmbeddingExecutor.kt @@ -0,0 +1,49 @@ +package pw.binom.agentik.memory + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pw.binom.voice.embeddingtext.TextEmbeddingExtractor + +/** + * Suspend-обёртка над [TextEmbeddingExtractor] из `pw.binom.ai.embeddingtext:api`. + * + * `TextEmbeddingExtractor.embed()` — **блокирующий** (ONNX-инференс, HTTP), + * поэтому [embed] оборачивает его в [Dispatchers.Default] — caller'ы получают + * честный suspend, а блокирующая работа уходит в background dispatcher. + * + * Размерность вектора фиксируется extractor'ом (SigLIP2-base = 768, OpenAI + * text-embedding-3 = 1536, и т.п.). Если [knownDimension] указан — используем + * его; иначе — определяем лениво по первому [embed] (probe-vector на пустом + * тексте). `MemoryVectorIndex`-ы требуют размерность на момент конструирования, + * так что для prod-использования рекомендуется всегда передавать [knownDimension] + * явно (избегаем лишнего embed'а + непредсказуемой стоимости probe'а). + * + * @param extractor underlying extractor (не null) + * @param knownDimension заранее известная размерность; null = определить по probe + */ +class TextEmbeddingExecutor( + val extractor: TextEmbeddingExtractor, + val knownDimension: Int? = null, +) : AutoCloseable { + + /** Размерность векторов. Эффективно константа после первого обращения. */ + val dimension: Int by lazy { + knownDimension ?: extractor.embed("").dim + } + + /** + * Эмбеддинг одного текста. Блокирующий [TextEmbeddingExtractor.embed] уходит + * в [Dispatchers.Default] — caller может безопасно await'ить. + */ + suspend fun embed(text: String): FloatArray = + withContext(Dispatchers.Default) { extractor.embed(text).values } + + /** Батч-эмбеддинг (последовательно). Для ONNX/HTTP оверхед минимален. */ + suspend fun embedBatch(texts: List): List = + texts.map { embed(it) } + + /** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */ + override fun close() { + extractor.close() + } +} diff --git a/memory-md-vector/build.gradle.kts b/memory-md-vector/build.gradle.kts new file mode 100644 index 0000000..aec9bc8 --- /dev/null +++ b/memory-md-vector/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +// :memory-md-vector — гибридное хранилище памяти: +// +// .md файлы (:memory-md, single source of truth) +// ↓ reconcile() на старте +// sqlite vector index (ksqlite + sqlite-vec vec0, derived cache) +// +// `.md` — единственный источник правды по метаданным и тексту заметок. +// Вектора — derived cache, перестраивается на старте и при `upsert`/`delete`. +// +// ANN-поиск: vector KNN (sqlite-vec MATCH) → top-50 → keyword rerank +// через `MdMemoryFormat.keywordScore` (vector 0.7 + keyword 0.3). +// +// Цели сборки — KMP: jvm() + linuxX64() + mingwX64(). До 2026-09-21 был +// JVM-only, потому что тащил `EmbeddingProvider` из JVM-only `:memory-vector`. +// С переходом на `TextEmbeddingExecutor` (из `:memory-api`, который тянет +// `pw.binom.ai.embeddingtext:api` — теперь KMP) модуль стал платформо- +// независимым. Под нативом тесты работают с `FakeTextEmbeddingExtractor`; +// прод-реализация (`:siglip` модуль text-embedding-kmp) пока JVM+Android only. + +kotlin { + jvmToolchain(21) + + jvm() + linuxX64() + mingwX64() + + sourceSets { + commonMain.dependencies { + // ksqlite 0.1.2 опубликован в Maven Central — обычный + // `mavenCentral()` в settings.gradle.kts его подтянет. + implementation("pw.binom.db:ksqlite:0.1.2") + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.io.core) + + api(project(":memory-api")) + implementation(project(":memory-md")) + // `text-embedding-api` тянется транзитивно через `:memory-api` + // (мы добавили `api(libs.text.embedding.api)` в memory-api/build.gradle.kts). + // Раньше тут стоял `implementation(project(":memory-vector"))` ради + // `EmbeddingProvider` — JVM-only модуль с JVector. Теперь не нужен. + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } +} diff --git a/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStore.kt b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStore.kt new file mode 100644 index 0000000..cca1cd3 --- /dev/null +++ b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStore.kt @@ -0,0 +1,206 @@ +package pw.binom.agentik.memory.mdvector + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemorySearchQuery +import pw.binom.agentik.memory.MemorySearchResult +import pw.binom.agentik.memory.MemoryStore +import pw.binom.agentik.memory.MemoryStoreEvent +import pw.binom.agentik.memory.TextEmbeddingExecutor +import pw.binom.agentik.memory.md.MdMemoryFormat +import pw.binom.agentik.memory.md.MdMemoryStore +import pw.binom.agentik.memory.noteMatches +import kotlin.time.Instant +import kotlinx.coroutines.flow.Flow + +/** + * Гибридное хранилище памяти: + * + * * `.md` файлы (через [MdMemoryStore]) — single source of truth по + * метаданным и тексту заметок; + * * ksqlite vector index ([KsqliteVectorIndex]) — derived cache embeddings + * и content_hash. + * + ** Архитектурный контракт: + * + * 1. Любая мутация (upsert/delete) обновляет оба слоя атомарно: сначала + * `.md` (через [MdMemoryStore]), потом векторный кэш. Если vector-write + * упал — `.md` уже сохранён; reconcile при следующем старте восстановит + * консистентность. + * + * 2. [search] использует vector ANN (sqlite-vec MATCH) → top-50 → keyword + * rerank (`MdMemoryFormat.keywordScore`). Финальный score = 0.7 * vector + * + 0.3 * keyword. Это даёт семантический recall с быстрой фильтрацией + * по точным совпадениям. + * + * 3. [reconcile] — вызывается при старте (из [openHybridMemoryStore]): + * - .md файл есть, вектора нет → embed + add; + * - .md файл есть, вектор есть, content_hash отличается → re-embed; + * - .md файла нет, вектор есть → orphan, remove. + * + * 4. Read-only методы ([get], [list], [markUsed], [archiveStale], [events]) + * делегируются в [MdMemoryStore] напрямую — никакой транзакции с + * vector-кэшем. + * + * Потокобезопасность: делегирующие методы — thread-safe за счёт + * `MdMemoryStore.mu`. Мутации векторов сериализуются + * [KsqliteVectorIndex.mutex]. Метод [reconcile] держит свой [mutex] для + * исключения конкурентных upsert'ов во время согласования. + */ +class HybridMdVectorStore internal constructor( + private val mdStore: MdMemoryStore, + private val vectorIndex: KsqliteVectorIndex, + private val embedder: TextEmbeddingExecutor, +) : MemoryStore { + + private val reconcileMutex = Mutex() + + /** + * Отчёт о согласовании `.md` ↔ vector-индекс. Возвращается из [reconcile]. + */ + data class ReconcileReport( + val added: Int, + val reembedded: Int, + val orphansRemoved: Int, + ) { + val totalChanged: Int get() = added + reembedded + orphansRemoved + } + + /** + * Согласовать vector-кэш с текущим состоянием `.md` файлов. + * + * Идемпотентен — повторный вызов no-op. + * + * Можно вызывать из фонового потока при старте `Main.kt` чтобы + * залогировать "reconciled: 5 re-embedded, 2 added, 0 orphans". + */ + suspend fun reconcile(): ReconcileReport = reconcileMutex.withLock { + val onDisk: List = mdStore.list(limit = Int.MAX_VALUE) + val onDiskById: Map = onDisk.associateBy { it.id } + val inCache: List = vectorIndex.allMeta() + + val cachedIds: Set = inCache.map { it.id }.toSet() + + var added = 0 + var reembedded = 0 + var orphansRemoved = 0 + + // 1) orphan-cleanup: vector есть, .md нет + for (cached in inCache) { + if (cached.id !in onDiskById) { + vectorIndex.remove(cached.id) + orphansRemoved++ + } + } + + // 2) re-embed / add + for (note in onDisk) { + val cached = inCache.firstOrNull { it.id == note.id } + val currentHash = note.contentHash() + if (cached == null) { + // .md есть, вектора нет → add + val vec = embedder.embed(note.content) + vectorIndex.add(note.id, vec, currentHash) + added++ + } else if (cached.contentHash != currentHash) { + // .md изменился → re-embed + val vec = embedder.embed(note.content) + vectorIndex.add(note.id, vec, currentHash) + reembedded++ + } + // else: cached.contentHash == currentHash → no-op + } + + ReconcileReport(added, reembedded, orphansRemoved) + } + + // ─── MemoryStore impl: мутации ───────────────────────────────────── + + override suspend fun upsert(note: MemoryNote) { + mdStore.upsert(note) + val vec = embedder.embed(note.content) + vectorIndex.add(note.id, vec, note.contentHash()) + } + + override suspend fun delete(id: String): Boolean { + val existed = mdStore.delete(id) + vectorIndex.remove(id) + return existed + } + + // ─── MemoryStore impl: search (hybrid) ──────────────────────────── + + override suspend fun search(query: MemorySearchQuery): List { + if (query.query.isBlank()) return emptyList() + if (query.topK <= 0) return emptyList() + + // Этап 1: vector ANN top-K (K=50 или больше topK). + val candidateK = maxOf(query.topK, VECTOR_CANDIDATES) + val qVec = embedder.embed(query.query) + val vectorHits = vectorIndex.search(qVec, candidateK) + + // Этап 2: загружаем кандидатов из .md (single source of truth) + // mapNotNull не умеет suspend, поэтому собираем вручную. + val candidates: List> = buildList(vectorHits.size) { + for (hit in vectorHits) { + val note = mdStore.get(hit.id) ?: continue + // Применяем категорийный/конво-фильтр ДО rerank — экономим keywordScore. + if (!noteMatches(note, query.category, query.conversationId)) continue + add(note to hit.score) + } + } + + // Этап 3: keyword rerank (vector 0.7 + keyword 0.3) + val rescored = candidates.map { (note, vecScore) -> + val kwScore = MdMemoryFormat.keywordScore(query.query, note) + val finalScore = vecScore * VECTOR_WEIGHT + kwScore * KEYWORD_WEIGHT + MemorySearchResult(note, finalScore) + }.sortedByDescending { it.score } + + return if (rescored.size > query.topK) rescored.subList(0, query.topK) else rescored + } + + // ─── MemoryStore impl: read-only delegation ─────────────────────── + + override suspend fun get(id: String): MemoryNote? = mdStore.get(id) + + override suspend fun list( + category: pw.binom.agentik.memory.MemoryCategory?, + conversationId: String?, + limit: Int, + offset: Int, + ): List = mdStore.list(category, conversationId, limit, offset) + + override suspend fun markUsed(id: String, at: Instant) = mdStore.markUsed(id, at) + + override suspend fun archiveStale( + maxAge: kotlin.time.Duration, + maxUseCount: Int, + now: Instant, + ): Int { + // Вектор-кэш не хранит lastUsedAt/useCount (только content_hash). + // Делегируем в mdStore — он сам знает что удалять; vector удалится + // каскадно при archiveStale → delete loop ниже. + val deleted = mdStore.archiveStale(maxAge, maxUseCount, now) + // Дополнительно чистим vector-кэш от записей, которых больше нет в .md + val remaining = mdStore.list(limit = Int.MAX_VALUE).map { it.id }.toSet() + vectorIndex.allMeta().forEach { entry -> + if (entry.id !in remaining) vectorIndex.remove(entry.id) + } + return deleted + } + + override fun events(): Flow = mdStore.events() + + override fun close() { + runCatching { vectorIndex.close() } + runCatching { mdStore.close() } + } + + companion object { + const val VECTOR_WEIGHT: Float = 0.7f + const val KEYWORD_WEIGHT: Float = 0.3f + const val VECTOR_CANDIDATES: Int = 50 + } +} diff --git a/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStoreOpen.kt b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStoreOpen.kt new file mode 100644 index 0000000..37db843 --- /dev/null +++ b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStoreOpen.kt @@ -0,0 +1,66 @@ +package pw.binom.agentik.memory.mdvector + +import kotlinx.io.files.Path +import pw.binom.agentik.memory.TextEmbeddingExecutor +import pw.binom.agentik.memory.md.openMdMemory +import pw.binom.db.ksqlite.SQLiteConnection + +/** + * Открыть гибридное хранилище памяти (`.md` + sqlite vector index). + * + * Создаёт: + * - [MdMemoryStore] на [memoryRoot] (`.md` файлы); + * - [KsqliteVectorIndex] на [vectorDbPath] (sqlite-vec vec0); + * - [HybridMdVectorStore] — обёртка с reconcile и hybrid search. + * + * Перед возвратом выполняет [HybridMdVectorStore.reconcile] — для свежей + * БД это приведёт к первичному embed'у всех `.md` файлов; для существующей — + * к re-embed'у изменившихся заметок и orphan-cleanup. + * + * @param memoryRoot директория с `.md` файлами (`USER.md`, `WORLD.md`, ...). + * @param vectorDbPath путь к файлу sqlite-БД для vector-кэша. + * @param dimension размерность embeddings от [embedder]. Фиксируется + * при создании индекса; дальнейшая смена = wipe БД. + * @param embedder провайдер embeddings. + * @param runReconcile выполнить [HybridMdVectorStore.reconcile] сразу после + * открытия. В тестах можно отключить для скорости. + */ +fun openHybridMemoryStore( + memoryRoot: Path, + vectorDbPath: Path, + dimension: Int, + embedder: TextEmbeddingExecutor, + runReconcile: Boolean = true, +): HybridMdVectorStore { + val md = openMdMemory(memoryRoot) + val conn = SQLiteConnection.open(vectorDbPath.toString()) + Schema.migrate(conn, dimension) + val idx = KsqliteVectorIndex(conn, dimension) + val hybrid = HybridMdVectorStore(md, idx, embedder) + if (runReconcile) { + kotlinx.coroutines.runBlocking { hybrid.reconcile() } + } + return hybrid +} + +/** + * In-memory вариант для тестов: vector-кэш в `:memory:` sqlite, + * `.md` — в `/tmp/agentik-hybrid-test-{random}`. + * + * Используется POSIX-путь `/tmp`, потому что [System.getenv] / [System.getProperty] + * недоступны в KMP commonMain (только JVM). На Windows mingwX64 этот вызов + * упадёт — там тесты пока не предполагаются, нативные тесты только linuxX64. + * Под JVM `/tmp` либо есть как symlink (Linux/macOS), либо стоит использовать + * jvmTest-специфичный factory. + */ +fun openInMemoryHybridMemoryStore( + dimension: Int, + embedder: TextEmbeddingExecutor, +): HybridMdVectorStore { + val tmpDir = Path("/tmp/agentik-hybrid-test-${kotlin.random.Random.nextLong()}") + val md = openMdMemory(tmpDir) + val conn = SQLiteConnection.memory("hybrid-${kotlin.random.Random.nextLong()}") + Schema.migrate(conn, dimension) + val idx = KsqliteVectorIndex(conn, dimension) + return HybridMdVectorStore(md, idx, embedder) +} diff --git a/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMemorySystem.kt b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMemorySystem.kt new file mode 100644 index 0000000..b3220e8 --- /dev/null +++ b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/HybridMemorySystem.kt @@ -0,0 +1,47 @@ +package pw.binom.agentik.memory.mdvector + +import kotlinx.io.files.Path +import pw.binom.agentik.memory.MemoryPrefetcher +import pw.binom.agentik.memory.MemoryReviewer +import pw.binom.agentik.memory.MemoryStore +import pw.binom.agentik.memory.MemorySystem +import pw.binom.agentik.memory.TextEmbeddingExecutor +import pw.binom.agentik.memory.md.KeywordMdPrefetcher +import pw.binom.agentik.memory.md.KeywordMdReviewer + +/** + * Связка [HybridMdVectorStore] + keyword-prefetcher + keyword-reviewer. + * + * Store делегирует I/O между .md (single source of truth) и sqlite-vector-кэшем; + * prefetcher и reviewer работают по .md-данным (через [HybridMdVectorStore]), + * так что обе роли видят консистентное состояние. + */ +class HybridMemorySystem internal constructor( + override val store: MemoryStore, + override val prefetcher: MemoryPrefetcher, + override val reviewer: MemoryReviewer, +) : MemorySystem { + override fun close() = store.close() +} + +/** + * Собирает [HybridMemorySystem] для указанной корневой директории + sqlite-БД. + * + * Под капотом: [HybridMdVectorStore] (md + vector), keyword-prefetcher из + * `:memory-md` (работает по store.api), keyword-reviewer без LLM — + * LLM-импл добавляется в `:standalone` поверх. + */ +fun openHybridMemorySystem( + memoryRoot: Path, + vectorDbPath: Path, + dimension: Int, + embedder: TextEmbeddingExecutor, + runReconcile: Boolean = true, +): HybridMemorySystem { + val store = openHybridMemoryStore(memoryRoot, vectorDbPath, dimension, embedder, runReconcile) + return HybridMemorySystem( + store = store, + prefetcher = KeywordMdPrefetcher(store), + reviewer = KeywordMdReviewer(), + ) +} diff --git a/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/KsqliteVectorIndex.kt b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/KsqliteVectorIndex.kt new file mode 100644 index 0000000..64103fb --- /dev/null +++ b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/KsqliteVectorIndex.kt @@ -0,0 +1,305 @@ +package pw.binom.agentik.memory.mdvector + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemoryVectorIndex +import pw.binom.agentik.memory.ScoredVector +import pw.binom.db.ksqlite.SQLiteConnection +import pw.binom.db.ksqlite.SQLitePreparedStatement +import kotlin.time.Clock + +/** + * ksqlite-реализация [MemoryVectorIndex] поверх `vec0` virtual table + * (sqlite-vec extension, встроен в ksqlite). + * + * Маппинг id → rowid: + * - TEXT `id` (== MemoryNote.id, "mem-...") лежит в [Schema.TABLE_META]. + * - `vec0` индексирует по `rowid` (INTEGER auto-increment). + * - JOIN через `WHERE vec0.rowid = meta.rowid`. + * + * На каждое [add] с contentHash рядом с вектором пишется meta с + * content_hash от [MemoryNote.contentHash]. Это позволяет reconcile'у + * в [HybridMdVectorStore] определить "изменилась ли заметка" без re-embed. + * + * Поиск — `vec0` MATCH (cosine distance, sqlite-vec native). Score + * конвертируется из distance (0..2, меньше = ближе) в similarity + * (0..1, больше = ближе). + * + * Конкурентность: write-операции сериализуются [mutex]; read'ы + * (`size`/`search`) не блокируют. + */ +class KsqliteVectorIndex internal constructor( + private val conn: SQLiteConnection, + override val dimension: Int, +) : MemoryVectorIndex { + + private val mutex = Mutex() + + private val insertVec: SQLitePreparedStatement = conn.prepare( + "INSERT INTO ${Schema.TABLE_VECTORS}(${Schema.COL_VECTOR}) VALUES (?)" + ) + + private val lastInsertRowIdStmt: SQLitePreparedStatement = conn.prepare( + "SELECT last_insert_rowid()" + ) + + private val insertMeta: SQLitePreparedStatement = conn.prepare( + """ + INSERT OR REPLACE INTO ${Schema.TABLE_META} + (${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH}, + ${Schema.COL_DIMENSION}, ${Schema.COL_UPDATED_AT}) + VALUES (?, ?, ?, ?, ?) + """.trimIndent() + ) + + private val findMetaByIdStmt: SQLitePreparedStatement = conn.prepare( + "SELECT ${Schema.COL_ROWID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?" + ) + + /** Запрос `... WHERE rowid IN (?, ?, ...)`. Подготавливаем на N=$MAX_INLINE_ROWIDS параметров. */ + private val findMetaByRowIdsStmt: SQLitePreparedStatement = conn.prepare( + (1..MAX_INLINE_ROWIDS).joinToString( + separator = ",", + prefix = "SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} IN (", + postfix = ")", + ) { "?" } + ) + + + private val deleteByRowIdStmt: SQLitePreparedStatement = conn.prepare( + "DELETE FROM ${Schema.TABLE_VECTORS} WHERE rowid = ?" + ) + + private val deleteMetaByRowIdStmt: SQLitePreparedStatement = conn.prepare( + "DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} = ?" + ) + + private val deleteMetaByIdStmt: SQLitePreparedStatement = conn.prepare( + "DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?" + ) + + private val sizeMetaStmt: SQLitePreparedStatement = conn.prepare( + "SELECT COUNT(*) FROM ${Schema.TABLE_META}" + ) + + private val allMetaStmt: SQLitePreparedStatement = conn.prepare( + "SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META}" + ) + + /** + * sqlite-vec требует чтобы `LIMIT` в MATCH-запросе был integer-литералом, + * а не `?`. Поэтому для search используем динамическую подготовку + * (кешированную по [k]). + * + * Vec0 KNN check (`sqlite-vec` source): «A LIMIT or 'k = ?' constraint is + * required on vec0 knn queries». Имя параметра `:k` тоже поддерживается, + * но у ksqlite bind API — только позиционный; literal проще. + */ + private val searchCache = HashMap() + + /** + * Выдать rowid для существующей записи или -1 если нет. + * + * **ВАЖНО**: вызывающий ОБЯЗАН держать [mutex]. Этот метод НЕ + * reentrant — повторный вход в [Mutex.withLock] приведёт к + * deadlock (kotlinx.coroutines.sync.Mutex не reentrant). + */ + private fun findRowIdLocked(id: String): Long { + findMetaByIdStmt.reset() + findMetaByIdStmt.clearBindings() + findMetaByIdStmt.bindText(1, id) + findMetaByIdStmt.executeQuery().use { rs -> + if (rs.next()) return rs.getLong(0) ?: -1L + } + return -1L + } + + override suspend fun size(): Long = mutex.withLock { + sizeMetaStmt.reset() + sizeMetaStmt.clearBindings() + sizeMetaStmt.executeQuery().use { rs -> + if (rs.next()) rs.getLong(0) ?: 0L else 0L + } + } + + override suspend fun add(id: String, embedding: FloatArray) { + require(embedding.size == dimension) { + "embedding dim=${embedding.size} != index dim=$dimension" + } + add(id, embedding, contentHash = "") + } + + /** + * Добавить или обновить запись с явным content_hash. + * Если запись с таким id уже есть — обновляет и вектор, и meta. + * Иначе — создаёт новый rowid. + */ + suspend fun add(id: String, embedding: FloatArray, contentHash: String) { + require(embedding.size == dimension) { + "embedding dim=${embedding.size} != index dim=$dimension" + } + mutex.withLock { + val existingRowId = findRowIdLocked(id) + val rowId: Long = if (existingRowId > 0) { + // Update: заменяем вектор по существующему rowid + insertVec.reset() + insertVec.clearBindings() + insertVec.bindVector(1, embedding) + insertVec.executeUpdate() + existingRowId + } else { + // Insert: получаем свежий rowid + insertVec.reset() + insertVec.clearBindings() + insertVec.bindVector(1, embedding) + insertVec.executeUpdate() + lastInsertRowIdStmt.reset() + lastInsertRowIdStmt.clearBindings() + lastInsertRowIdStmt.executeQuery().use { rs -> + if (rs.next()) rs.getLong(0) ?: error("no last_insert_rowid()") else error("no last_insert_rowid()") + } + } + + insertMeta.reset() + insertMeta.clearBindings() + insertMeta.bindLong(1, rowId) + insertMeta.bindText(2, id) + insertMeta.bindText(3, contentHash) + insertMeta.bindLong(4, dimension.toLong()) + insertMeta.bindLong(5, Clock.System.now().toEpochMilliseconds()) + insertMeta.executeUpdate() + } + } + + override suspend fun remove(id: String): Boolean = mutex.withLock { + val rowId = findRowIdLocked(id) + if (rowId <= 0) return@withLock false + + // Удаляем meta сначала — иначе orphan-row в vec0. + deleteMetaByIdStmt.reset() + deleteMetaByIdStmt.clearBindings() + deleteMetaByIdStmt.bindText(1, id) + deleteMetaByIdStmt.executeUpdate() + + deleteByRowIdStmt.reset() + deleteByRowIdStmt.clearBindings() + deleteByRowIdStmt.bindLong(1, rowId) + deleteByRowIdStmt.executeUpdate() + true + } + + /** Прочитать content_hash для id. null если записи нет. */ + suspend fun getContentHash(id: String): String? = mutex.withLock { + findMetaByIdStmt.reset() + findMetaByIdStmt.clearBindings() + findMetaByIdStmt.bindText(1, id) + findMetaByIdStmt.executeQuery().use { rs -> + if (rs.next()) rs.getText(1) else null + } + } + + /** Полный список (rowid, id, contentHash) для reconcile'а. */ + suspend fun allMeta(): List = mutex.withLock { + allMetaStmt.reset() + allMetaStmt.clearBindings() + val out = mutableListOf() + allMetaStmt.executeQuery().use { rs -> + while (rs.next()) { + val rowId = rs.getLong(0) ?: continue + val id = rs.getText(1) ?: continue + val hash = rs.getText(2) ?: continue + out.add(MetaEntry(rowId, id, hash)) + } + } + out + } + + override suspend fun search( + query: FloatArray, + k: Int, + filter: (MemoryNote) -> Boolean, + ): List { + require(query.size == dimension) { + "query dim=${query.size} != index dim=$dimension" + } + val safeK = k.coerceAtLeast(1) + return mutex.withLock { + // sqlite-vec MATCH требует минимальный запрос без JOIN/лишних + // ORDER BY — иначе "A LIMIT or 'k = ?' constraint is required". + // Поэтому делаем два запроса: + // 1) vec0 ANN → (rowid, distance) + // 2) meta lookup по собранным rowid → id + val stmt = searchCache.getOrPut(safeK) { + conn.prepare( + """ + SELECT rowid, distance + FROM ${Schema.TABLE_VECTORS} + WHERE ${Schema.COL_VECTOR} MATCH ? + ORDER BY distance + LIMIT $safeK + """.trimIndent() + ) + } + stmt.reset() + stmt.clearBindings() + stmt.bindVector(1, query) + + val candidates = mutableListOf>() + stmt.executeQuery().use { rs -> + while (rs.next()) { + val rowId = rs.getLong(0) ?: continue + val distance = rs.getDouble(1) ?: continue + val score = ((1.0 - distance / 2.0) * 1.0).toFloat().coerceIn(0f, 1f) + candidates.add(rowId to score) + } + } + + if (candidates.isEmpty()) return@withLock emptyList() + + // 2-й запрос: meta по списку rowid. + val rowIds = candidates.map { it.first } + val idByRowId = HashMap(candidates.size) + findMetaByRowIdsStmt.reset() + findMetaByRowIdsStmt.clearBindings() + for ((idx, rowId) in rowIds.withIndex()) { + findMetaByRowIdsStmt.bindLong(idx + 1, rowId) + } + findMetaByRowIdsStmt.executeQuery().use { rs -> + while (rs.next()) { + val rowId = rs.getLong(0) ?: continue + val id = rs.getText(1) ?: continue + idByRowId[rowId] = id + } + } + + candidates.mapNotNull { (rowId, score) -> + idByRowId[rowId]?.let { ScoredVector(it, score) } + } + } + } + + override suspend fun flush() { + // ksqlite + WAL — flush не нужен. Метод для совместимости с интерфейсом. + } + + override fun close() { + insertVec.close() + lastInsertRowIdStmt.close() + insertMeta.close() + findMetaByIdStmt.close() + deleteByRowIdStmt.close() + deleteMetaByRowIdStmt.close() + deleteMetaByIdStmt.close() + sizeMetaStmt.close() + allMetaStmt.close() + searchCache.values.forEach { it.close() } + } + + data class MetaEntry(val rowId: Long, val id: String, val contentHash: String) + + private companion object { + /** Максимум rowid, которые мы зашиваем в `IN (?,?,...)` одним prepared statement'ом. */ + const val MAX_INLINE_ROWIDS = 256 + } +} diff --git a/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/Schema.kt b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/Schema.kt new file mode 100644 index 0000000..cc69fe7 --- /dev/null +++ b/memory-md-vector/src/commonMain/kotlin/pw/binom/agentik/memory/mdvector/Schema.kt @@ -0,0 +1,80 @@ +package pw.binom.agentik.memory.mdvector + +import pw.binom.db.ksqlite.SQLiteConnection + +/** + * DDL/DML для ksqlite-бэкенда `:memory-md-vector`. + * + * Две таблицы: + * + * * `memory_vectors` (vec0) — ANN-индекс. Содержит embedding + первичный + * ключ `rowid` (auto-increment INTEGER, sqlite-vec требует именно его). + * Размерность задаётся `float[DIMENSION]` при создании. + * + * * `memory_meta` — рядом с вектором: TEXT `id` (== MemoryNote.id) + + * `rowid` (тот же, что в vec0) + `content_hash` (от MemoryNote.contentHash()). + * Используется reconcile'ом — если хэш в meta не совпадает с тем, что + * вычисляется из текущего `.md` файла → re-embed. + * + * JOIN между vec0 и meta: `WHERE vec0.rowid = meta.rowid`. + * + * Миграция через `PRAGMA user_version` (как в `:journal-ksqlite/Schema.kt`). + */ +internal object Schema { + + const val CURRENT_VERSION: Int = 1 + + const val TABLE_VECTORS = "memory_vectors" + const val TABLE_META = "memory_meta" + + const val COL_ID = "id" + const val COL_ROWID = "rowid" + const val COL_VECTOR = "embedding" + const val COL_HASH = "content_hash" + const val COL_DIMENSION = "dimension" + const val COL_UPDATED_AT = "updated_at" + + fun v1Ddl(dimension: Int): String = """ + CREATE VIRTUAL TABLE IF NOT EXISTS $TABLE_VECTORS USING vec0( + $COL_VECTOR float[$dimension] + ); + CREATE TABLE IF NOT EXISTS $TABLE_META ( + $COL_ROWID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + $COL_ID TEXT NOT NULL UNIQUE, + $COL_HASH TEXT NOT NULL, + $COL_DIMENSION INTEGER NOT NULL, + $COL_UPDATED_AT INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_meta_id ON $TABLE_META($COL_ID); + """.trimIndent() + + fun migrate(conn: SQLiteConnection, dimension: Int) { + val current = readUserVersion(conn) + if (current >= CURRENT_VERSION) return + + conn.exec("BEGIN") + try { + if (current < 1) { + conn.exec(v1Ddl(dimension)) + } + writeUserVersion(conn, CURRENT_VERSION) + conn.exec("COMMIT") + } catch (t: Throwable) { + runCatching { conn.exec("ROLLBACK") } + throw t + } + } + + private fun readUserVersion(conn: SQLiteConnection): Int { + conn.prepare("PRAGMA user_version").use { stmt -> + stmt.executeQuery().use { rs -> + if (rs.next()) return rs.getLong(0)?.toInt() ?: 0 + } + } + return 0 + } + + private fun writeUserVersion(conn: SQLiteConnection, version: Int) { + conn.exec("PRAGMA user_version = $version") + } +} diff --git a/memory-md-vector/src/commonTest/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStoreTest.kt b/memory-md-vector/src/commonTest/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStoreTest.kt new file mode 100644 index 0000000..c0d3743 --- /dev/null +++ b/memory-md-vector/src/commonTest/kotlin/pw/binom/agentik/memory/mdvector/HybridMdVectorStoreTest.kt @@ -0,0 +1,166 @@ +package pw.binom.agentik.memory.mdvector + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import kotlinx.coroutines.runBlocking as kRunBlocking +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemorySearchQuery +import pw.binom.agentik.memory.MemorySource +import pw.binom.agentik.memory.TextEmbeddingExecutor +import pw.binom.voice.embeddingtext.TextEmbedding +import pw.binom.voice.embeddingtext.TextEmbeddingExtractor + +/** + * Тесты гибридного стора: делегирование в .md, vector ANN, reconcile, + * hybrid search rerank. + * + * Используется [kRunBlocking] (а не `runTest`) потому что `MdMemoryStore` + * делает реальный файловый I/O (`kotlinx-io`), который плохо дружит с + * TestDispatcher'ом — `runTest` зависает на virtual-time I/O. + */ +class HybridMdVectorStoreTest { + + /** + * Детерминированный [TextEmbeddingExtractor] для тестов модуля. + * Хеширует текст в псевдо-вектор фиксированной размерности, L2-normalize. + * Заворачивается в [TextEmbeddingExecutor] с пред-объявленной размерностью. + */ + private fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor = + TextEmbeddingExecutor(FakeTestExtractor(dimension), knownDimension = dimension) + + private class FakeTestExtractor(val dim: Int) : TextEmbeddingExtractor { + override fun embed(text: String): TextEmbedding { + val v = FloatArray(dim) + var seed = text.hashCode().toLong() and 0xFFFFFFFFL + for (i in 0 until dim) { + seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL + v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f + } + var norm = 0f + for (x in v) norm += x * x + norm = kotlin.math.sqrt(norm) + if (norm > 0f) for (i in v.indices) v[i] /= norm + return TextEmbedding(v) + } + override fun close() = Unit + } + + private fun note( + id: String, + content: String, + category: MemoryCategory = MemoryCategory.USER, + source: MemorySource = MemorySource.USER_EXPLICIT, + ) = MemoryNote( + id = id, + category = category, + content = content, + createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L), + lastUsedAt = Instant.fromEpochMilliseconds(1_700_000_000_000L), + useCount = 0, + conversationId = null, + source = source, + ) + + @Test + fun upsertWritesToBothMdAndVectorCache() = kRunBlocking { + val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32)) + store.upsert(note("mem-1", "user prefers dark mode")) + + val fromMd = store.get("mem-1") + assertNotNull(fromMd) + assertEquals("user prefers dark mode", fromMd.content) + + val hits = store.search(MemorySearchQuery(query = "user prefers dark mode", topK = 5)) + assertEquals(1, hits.size) + assertEquals("mem-1", hits.first().note.id) + + store.close() + } + + @Test + fun deleteRemovesFromBothLayers() = kRunBlocking { + val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32)) + store.upsert(note("mem-2", "lives in Saint Petersburg")) + store.upsert(note("mem-3", "loves Kotlin multiplatform")) + + assertEquals(2, store.list(limit = 10).size) + + val removed = store.delete("mem-2") + assertTrue(removed) + + assertEquals(1, store.list(limit = 10).size) + assertNull(store.get("mem-2")) + + val hits = store.search(MemorySearchQuery(query = "Saint Petersburg", topK = 5)) + assertTrue(hits.isEmpty() || hits.all { it.note.id != "mem-2" }) + + store.close() + } + + @Test + fun reconcileOnEmptyStoreIsNoOp() = kRunBlocking { + val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32)) + val report = store.reconcile() + assertEquals(0, report.added) + assertEquals(0, report.reembedded) + assertEquals(0, report.orphansRemoved) + store.close() + } + + @Test + fun reconcileIsIdempotent() = kRunBlocking { + val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32)) + store.upsert(note("mem-10", "works at Binom")) + val report = store.reconcile() + assertEquals(0, report.totalChanged, "идемпотентность reconcile: повторный вызов no-op") + + store.close() + } + + @Test + fun searchReturnsRelevantResultsByKeyword() = kRunBlocking { + val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32)) + store.upsert(note("mem-a", "kotlin multiplatform")) + store.upsert(note("mem-b", "java enterprise")) + store.upsert(note("mem-c", "kotlin coroutines")) + + val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 5)) + val ids = hits.map { it.note.id }.toSet() + assertTrue("mem-a" in ids, "expected 'kotlin multiplatform' in results: $ids") + assertTrue("mem-c" in ids, "expected 'kotlin coroutines' in results: $ids") + + store.close() + } + + @Test + fun searchRespectsCategoryFilter() = kRunBlocking { + val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32)) + store.upsert(note("user-1", "kotlin lover", MemoryCategory.USER)) + store.upsert(note("world-1", "kotlin 2.0 released", MemoryCategory.WORLD)) + + val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 10, category = MemoryCategory.USER)) + assertEquals(1, hits.size) + assertEquals("user-1", hits.first().note.id) + + store.close() + } + + @Test + fun hybridScoreCombinesVectorAndKeyword() = kRunBlocking { + val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32)) + store.upsert(note("mem-x", "kotlin multiplatform project")) + + val hits = store.search(MemorySearchQuery(query = "kotlin multiplatform", topK = 1)) + assertEquals(1, hits.size) + val score = hits.first().score + assertTrue(score in 0f..1f, "score $score out of range") + assertTrue(score > 0.5f, "expected hybrid score > 0.5, got $score") + + store.close() + } +} diff --git a/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/FakeTextEmbeddingExtractor.kt b/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/FakeTextEmbeddingExtractor.kt new file mode 100644 index 0000000..b995874 --- /dev/null +++ b/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/FakeTextEmbeddingExtractor.kt @@ -0,0 +1,41 @@ +package pw.binom.agentik.memory.vector + +import pw.binom.agentik.memory.TextEmbeddingExecutor +import pw.binom.voice.embeddingtext.TextEmbedding +import pw.binom.voice.embeddingtext.TextEmbeddingExtractor + +/** + * Детерминированный [TextEmbeddingExtractor] для тестов: хеширует текст в + * псевдо-вектор фиксированной размерности. L2-normalize чтобы cosine + * работал осмысленно. + * + * НЕ suspend, как и положено extractor'у — suspend-обёртка живёт в + * [TextEmbeddingExecutor] (используется в VectorMemoryStore через + * [fakeExecutor]). + */ +class FakeTextEmbeddingExtractor( + val dimension: Int = 32, +) : TextEmbeddingExtractor { + override fun embed(text: String): TextEmbedding { + val v = FloatArray(dimension) + var seed = text.hashCode().toLong() and 0xFFFFFFFFL + for (i in 0 until dimension) { + seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL + v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f + } + var norm = 0f + for (x in v) norm += x * x + norm = kotlin.math.sqrt(norm) + if (norm > 0f) for (i in v.indices) v[i] /= norm + return TextEmbedding(v) + } + + override fun close() = Unit +} + +/** + * Удобная обёртка для тестов: создаёт `FakeTextEmbeddingExtractor` и + * сразу заворачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью. + */ +fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor = + TextEmbeddingExecutor(FakeTextEmbeddingExtractor(dimension), knownDimension = dimension) diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/AgentEvent.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/AgentEvent.kt new file mode 100644 index 0000000..c360351 --- /dev/null +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/AgentEvent.kt @@ -0,0 +1,48 @@ +package pw.binom.agentik.outbox + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +/** + * Live-события уровня агента: изменения в множестве диалогов + * (создание, удаление, переименование). События, происходящие **внутри** + * конкретного диалога, приходят через `Conversation.events` (live-stream + * per-turn Event'ов), а не сюда. + * + * Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки + * идентична `OutboxStore.events`: поток **не реплеит** прошлое, для бэкфилла + * используются `Agent.getConversations` / `getConversation`. + * + * **История**: раньше жил в `:proto` (как `pw.binom.agentik.proto.AgentEvent`). + * После миграции в `:outbox-api` — `:proto.AgentEvent` стал typealias'ом, + * backward-compat для существующих импортов сохранён. + */ +@Serializable +sealed interface AgentEvent { + /** Момент эмиссии события в UTC. */ + val date: Instant + + /** + * Создан новый диалог. Передаётся его id — handle можно получить через + * `Agent.getConversation`. Подписчик после [Created] может сразу открыть + * live-подписку на этот диалог через `Conversation.events`. + */ + @Serializable + @SerialName("created") + data class Created(override val date: Instant, val conversationId: String) : AgentEvent + + /** + * Диалог удалён. Переданный `Conversation`-handle реализация обязана + * закрыть (`close()`) до эмиссии этого события — после [Deleted] + * пользоваться handle нельзя. + */ + @Serializable + @SerialName("deleted") + data class Deleted(override val date: Instant, val id: String) : AgentEvent + + /** У диалога сменился заголовок. */ + @Serializable + @SerialName("renamed") + data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent +} diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt new file mode 100644 index 0000000..847235f --- /dev/null +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/CommonEvent.kt @@ -0,0 +1,42 @@ +package pw.binom.agentik.outbox + +import kotlin.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Unified wrapper for all agent events in a single stream. + * + * Useful for admin dashboards, debug tools, parent agents: one subscription + * instead of N+1. For regular UI use two separate SSE feeds + * ([AgentEvent] via `/events` и `Event` via `/conversations/{id}/events`); + * [CommonEvent] — for those who need everything in one place. + * + * Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`. + * + * **История**: раньше жил в `:proto` (как `pw.binom.agentik.proto.CommonEvent`). + * После миграции в `:outbox-api` — `:proto.CommonEvent` стал typealias'ом, + * backward-compat для существующих импортов сохранён. `CommonEvent.Conversation` + * ссылается на [Event] (тоже в `:outbox-api` теперь) — раньше был + * `pw.binom.agentik.proto.Event`, теперь это `pw.binom.agentik.outbox.Event` + * (он тоже typealias-нут в `:proto.Event`). + */ +@Serializable +sealed interface CommonEvent { + val date: Instant + + @Serializable + @SerialName("agent") + data class Agent( + override val date: Instant, + val event: AgentEvent, + ) : CommonEvent + + @Serializable + @SerialName("conversation") + data class Conversation( + override val date: Instant, + val conversationId: String, + val event: Event, + ) : CommonEvent +} diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt new file mode 100644 index 0000000..66b297c --- /dev/null +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt @@ -0,0 +1,92 @@ +package pw.binom.agentik.outbox + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +/** + * Элемент live-потока `Conversation.events(after)`. + * + * Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом + * для трекинга «где остановился» при обрыве/переподключении и для разрешения + * порядка при равных timestamps. + * + * Базовая структура хода: + * `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`. + * `StartReasoning` может отсутствовать, если агент не показывал рассуждения. + * + * **История**: раньше жил в `:proto` (как `pw.binom.agentik.proto.Event`). + * После миграции в `:outbox-api` — `:proto.Event` стал typealias'ом, + * backward-compat для существующих импортов сохранён. + */ +@Serializable +sealed interface Event { + /** Момент эмиссии события в UTC. */ + val date: Instant + + @Serializable + enum class ResponseType { + @SerialName("text") TEXT, + @SerialName("image") IMAGE + } + + /** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */ + @Serializable + @SerialName("start_reasoning") + data class StartReasoning(override val date: Instant) : Event + + /** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */ + @Serializable + @SerialName("start_response") + data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event + + /** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */ + @Serializable + @SerialName("end") + data class End(override val date: Instant) : Event + + /** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */ + @Serializable + @SerialName("interrupted") + data class Interrupted(override val date: Instant) : Event + + @Serializable + @SerialName("append_text") + data class AppendText(override val date: Instant, val body: String) : Event + + @Serializable + @SerialName("append_image") + data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event + + /** + * Агент начал вызов тула. Аргументы приходят целиком — стриминга нет. + * [id] совпадает с id соответствующего `Message.ToolCall` в истории + * после завершения хода. + */ + @Serializable + @SerialName("tool_call") + data class ToolCall( + override val date: Instant, + val id: String, + val title: String?, + val toolName: String, + val toolArgs: String, + ) : Event + + /** + * Результат вызова тула. Приходит целиком после завершения исполнения. + * [id] совпадает с [ToolCall.id], к которому относится результат, и + * с id `Message.ToolResult` в истории. + */ + @Serializable + @SerialName("tool_result") + data class ToolResult(override val date: Instant, val id: String, val result: String?) : Event + + /** + * Ошибка хода. После неё поток завершается; дальнейшие события могут + * прийти, но ход считается проваленным. + */ + @Serializable + @SerialName("error") + data class Error(override val date: Instant, val message: String, val code: String? = null) : Event +} diff --git a/reflection-api/build.gradle.kts b/reflection-api/build.gradle.kts new file mode 100644 index 0000000..4bd149a --- /dev/null +++ b/reflection-api/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +// Public API для хранения self-reflection записей агента. Не зависит от +// journal/context/outbox — reflection это отдельная сущность (рантайм-мета +// о качестве последних ходов). + +kotlin { + jvmToolchain(21) + + jvm() + macosX64() + macosArm64() + iosX64() + iosArm64() + iosSimulatorArm64() + linuxX64() + linuxArm64() + mingwX64() + + sourceSets { + commonMain.dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.serialization.core) + api(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } +} diff --git a/reflection-api/src/commonMain/kotlin/pw/binom/agentik/reflection/ReflectionStore.kt b/reflection-api/src/commonMain/kotlin/pw/binom/agentik/reflection/ReflectionStore.kt new file mode 100644 index 0000000..d5533ee --- /dev/null +++ b/reflection-api/src/commonMain/kotlin/pw/binom/agentik/reflection/ReflectionStore.kt @@ -0,0 +1,59 @@ +package pw.binom.agentik.reflection + +import kotlinx.coroutines.flow.Flow +import kotlin.time.Instant + +/** + * Self-reflection запись — что агент "думает" о качестве своих последних ходов. + * + * - [score]: 1..5 (самооценка качества) + * - [weakSpots]: конкретные слабые места ("медленно ищу в Y", "путаю A и B") + * - [summary]: свободный комментарий в markdown (что заметил, что улучшить) + * + * Источник: one-shot LLM-размышление после каждых N ходов (см. AGENTIK_REFLECTION_INTERVAL). + * Используется в `ChatAgent.buildSystemPrompt` как "слабые места за последнее время". + */ +data class Reflection( + val id: String, + val conversationId: String?, + val createdAt: Instant, + val turnsAnalyzed: Int, + val score: Int, + val summary: String, + val weakSpots: List, +) + +/** + * Хранилище рефлексий. Backed by SQLDelight `reflection` таблицу (impl в + * `:storage-sqlite`) или in-memory (impl в `:storage-inmemory`). + * + * Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup) + * или архивируются через [Curator]-подобный процесс, но не редактируются. + */ +interface ReflectionStore : AutoCloseable { + suspend fun insert(reflection: Reflection) + suspend fun get(id: String): Reflection? + /** Самые свежие рефлексии (по всему агенту). */ + suspend fun listRecent(limit: Int = 10): List + /** Рефлексии для конкретного диалога. */ + suspend fun listForConversation(conversationId: String, limit: Int = 10): List + suspend fun deleteOlderThan(cutoff: Instant) + suspend fun count(): Int + + /** Стрим новых рефлексий для подписчиков (для UI в будущем). */ + fun events(): Flow = kotlinx.coroutines.flow.emptyFlow() + + override fun close() +} + +sealed interface ReflectionEvent { + data class Created(val reflection: Reflection) : ReflectionEvent +} + +/** + * Генератор id для reflection-записей. Префикс `refl-` чтобы в логах и + * БД-схемах было видно сразу. + */ +object Ids { + fun new(): String = "refl-${kotlin.uuid.Uuid.random()}" +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt new file mode 100644 index 0000000..b7f146e --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt @@ -0,0 +1,74 @@ +package pw.binom.agentik.standalone.agent + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.reflection.Reflection + +class ChatAgentReflectionTest { + + @Test + fun `buildSystemPrompt omits section when reflections empty`() { + val prompt = buildSystemPrompt( + base = "base", + skills = SkillCatalog.EMPTY, + memoryEnabled = false, + soulBody = null, + reflections = emptyList(), + ) + assertFalse(prompt.contains("Self-reflection")) + assertFalse(prompt.contains("слабые места")) + } + + @Test + fun `buildSystemPrompt includes section with weak spots when reflections non-empty`() { + val r = Reflection( + id = "r1", + conversationId = null, + createdAt = Instant.parse("2026-09-15T12:00:00Z"), + turnsAnalyzed = 10, + score = 2, + summary = "плохо", + weakSpots = listOf("медленно отвечаю на X", "путаю A и B"), + ) + val prompt = buildSystemPrompt( + base = "base", + skills = SkillCatalog.EMPTY, + memoryEnabled = false, + soulBody = null, + reflections = listOf(r), + ) + assertTrue(prompt.contains("Self-reflection")) + assertTrue(prompt.contains("слабые места")) + assertTrue(prompt.contains("медленно отвечаю на X")) + assertTrue(prompt.contains("путаю A и B")) + assertTrue(prompt.contains("score=2/5")) + } + + @Test + fun `buildSystemPrompt places reflection section after memory and before soul prepend`() { + val r = Reflection( + id = "r1", + conversationId = null, + createdAt = Instant.parse("2026-09-15T12:00:00Z"), + turnsAnalyzed = 5, + score = 4, + summary = "ok", + weakSpots = listOf("minor issue"), + ) + val prompt = buildSystemPrompt( + base = "base", + skills = SkillCatalog.EMPTY, + memoryEnabled = true, + soulBody = "I am a soul", + reflections = listOf(r), + ) + // soul первый, reflection последняя + val soulIdx = prompt.indexOf("I am a soul") + val reflIdx = prompt.indexOf("Self-reflection") + assertTrue(soulIdx >= 0 && reflIdx >= 0) + assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection") + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt new file mode 100644 index 0000000..285a113 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -0,0 +1,639 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.outbox.AgentEvent +import pw.binom.agentik.proto.Content +import pw.binom.agentik.outbox.Event as ProtoEvent +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillFile +import pw.binom.agentik.standalone.llm.LlmBackend +import pw.binom.agentik.standalone.llm.LlmConfig +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.context.WorkingMemoryEntry +import pw.binom.agentik.storage.ksqlite.KsqliteStores +import pw.binom.litert.LiteContentPart +import pw.binom.litert.LiteConversation +import pw.binom.litert.LiteConversationConfig +import pw.binom.litert.LiteDelta +import pw.binom.litert.LiteLlm +import pw.binom.litert.LiteMessage +import pw.binom.litert.LiteRole +import pw.binom.litert.LiteTool +import pw.binom.litert.LiteToolCall +import pw.binom.agentik.standalone.llm.OpenAiConfig +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import pw.binom.agentik.toolsets.NamedTool + +class ChatAgentTest { + + private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores + private lateinit var fakeLlm: FakeLiteLlm + + @BeforeTest + fun setup() { + sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}") + fakeLlm = FakeLiteLlm() + } + + @AfterTest + fun tearDown() { + sqliteStores.close() + } + + private fun newAgent( + sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores = this.sqliteStores, + llm: LiteLlm = this.fakeLlm, + tools: List = emptyList(), + skills: SkillCatalog = SkillCatalog.EMPTY, + ): ChatAgent = ChatAgent( + id = "agentik", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = llm, + llmConfig = LlmConfig( + backend = LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + tools = tools, + skills = skills, + ) + + @Test + fun `createConversation does NOT seed system prompt into working memory`() = runTest { + // System prompt живёт ТОЛЬКО in-memory в ChatConversation.systemPrompt + // и едет в LLM через LiteConversationConfig.systemInstruction. В + // working_memory ничего не пишется — старт system prompt чистый, + // 0 entries. + val agent = newAgent() + val conv = agent.createConversation(temp = false) as ChatConversation + + val wm = sqliteStores.workingMemory.list(conv.id) + assertEquals(0, wm.size) + // System prompt виден через LiteConversationConfig, который LLM получит + // при первом send (см. `send passes system prompt and past history to LLM on first send`). + } + + @Test + fun `skills are appended to the system prompt passed to LLM`() = runTest { + // Skills добавляются в system prompt на лету при сборке ChatAgent. + // Проверяем это через то, что увидит LLM — systemInstruction в + // LiteConversationConfig (а не через working_memory, куда теперь + // ничего про system prompt не пишется). + val skills = SkillCatalog( + listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), + ) + val agent = newAgent(skills = skills) + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + + val system = fakeLlm.lastConfig!!.systemInstruction + assertNotNull(system) + assertTrue("be brief" in system!!, "base prompt missing: $system") + assertTrue("## Навыки" in system, "skills section missing: $system") + assertTrue("lint" in system, "skill name missing: $system") + assertTrue("lint things" in system, "skill description missing: $system") + assertFalse("SECRET BODY" in system, "system prompt must not leak the skill body") + } + + @Test + fun `read_skill tool is registered when skills present`() = runTest { + val skills = SkillCatalog( + listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), + ) + val agent = newAgent(skills = skills) + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + + val descriptors = fakeLlm.lastConfig!!.tools.map { it.describe() } + assertTrue(descriptors.any { SkillReadTool.NAME in it }, "expected read_skill tool: $descriptors") + } + + @Test + fun `no read_skill tool when skills absent`() = runTest { + val agent = newAgent() + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + + val descriptors = fakeLlm.lastConfig?.tools?.map { it.describe() } ?: emptyList() + assertTrue(descriptors.none { SkillReadTool.NAME in it }, "unexpected read_skill tool: $descriptors") + } + + @Test + fun `getConversation returns null for unknown id`() = runTest { + val agent = newAgent() + assertNull(agent.getConversation("nope")) + } + + @Test + fun `getConversations returns all stored persistent conversations`() = runTest { + val agent = newAgent() + agent.createConversation(temp = false) + agent.createConversation(temp = true) + val list = agent.getConversations(0, 10) + // temp-беседы не персистятся, в списке только persistent + assertEquals(1, list.size) + } + + @Test + fun `deleteConversation removes conversation and data`() = runTest { + val agent = newAgent() + val conv = agent.createConversation(temp = false) + val id = conv.id + + // добавим сообщение, чтобы потом убедиться, что каскад сработал + sqliteStores.messages.append( + pw.binom.agentik.journal.MessageRecord.UserMessage( + id = "m1", + conversationId = id, + content = listOf(pw.binom.agentik.journal.Content.Text("hi")), + createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000), + ), + ) + assertTrue(agent.deleteConversation(id)) + assertNull(agent.getConversation(id)) + assertNull(sqliteStores.conversations.get(id)) + assertEquals(emptyList(), sqliteStores.messages.listFlow(id, Instant.DISTANT_PAST).toList()) + } + + @Test + fun `deleteConversation returns false for unknown id`() = runTest { + val agent = newAgent() + assertEquals(false, agent.deleteConversation("nope")) + } + + @Test + fun `send emits start_reasoning, start_response, append_text, end`() = runTest { + val agent = newAgent() + fakeLlm.reply = "hello back" + val conv = agent.createConversation(temp = false) + + conv.send(listOf(Content.Text("hi"))) + + // user message записан в audit + working memory + val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() + assertEquals(2, msgs.size) + assertEquals("hi", (msgs[0] as pw.binom.agentik.journal.MessageRecord.UserMessage).content.let { + (it[0] as pw.binom.agentik.journal.Content.Text).body + }) + assertEquals("hello back", (msgs[1] as pw.binom.agentik.journal.MessageRecord.AssistantMessage).content.let { + (it[0] as pw.binom.agentik.journal.Content.Text).body + }) + } + + @Test + fun `send reconstructs conversation history from working memory`() = runTest { + val agent = newAgent() + fakeLlm.rememberHistory = true + fakeLlm.reply = "first reply" + val conv1 = agent.createConversation(temp = false) + conv1.send(listOf(Content.Text("first user"))) + + // Новая беседа не должна видеть историю первой + val conv2 = agent.createConversation(temp = false) + fakeLlm.reply = "second reply" + conv2.send(listOf(Content.Text("second user"))) + + // В working_memory теперь НЕТ System-entries — только user + assistant. + // Системный промт живёт в ChatConversation.systemPrompt и едет в LLM + // через LiteConversationConfig.systemInstruction. + val wm1 = sqliteStores.workingMemory.list(conv1.id) + assertEquals(2, wm1.size) + val wm2 = sqliteStores.workingMemory.list(conv2.id) + assertEquals(2, wm2.size) + } + + @Test + fun `send passes system prompt and past history to LLM on first send`() = runTest { + val agent = newAgent() + fakeLlm.rememberHistory = true + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "hi" + conv.send(listOf(Content.Text("hello"))) + + // Длинно-живущий LiteConversation: первый send создаёт его с systemInstruction + // и пустыми initialMessages (свежее user-сообщение пойдёт через sendStreamContents). + assertNotNull(fakeLlm.lastConfig) + assertEquals("be brief", fakeLlm.lastConfig!!.systemInstruction) + assertEquals(0, fakeLlm.lastConfig!!.initialMessages.size) + // Свежее user-сообщение отправлено через sendStreamContents + assertEquals(1, fakeLlm.conversations.size) + val sent = fakeLlm.lastContents + assertNotNull(sent) + assertEquals(1, sent.size) + assertEquals("hello", (sent[0] as LiteContentPart.Text).text) + } + + @Test + fun `multi-turn conversation accumulates history but recreates LiteConv each turn`() = runTest { + // Новая семантика (radical close+recreate после commit 7): + // каждый turn закрывает LiteConv и на следующем send() создаёт новую + // через getOrCreateLiteConversation, которая пересобирает initialMessages + // из working memory. То есть LiteConv — один на turn, не на диалог. + // Преимущество: interrupt можно сделать тривиально (close + cancelProcess), + // KV-cache жертвуем ради предсказуемости (~2s prefill на Gemma-4-E2B). + val agent = newAgent() + fakeLlm.rememberHistory = true + val conv = agent.createConversation(temp = false) + + fakeLlm.reply = "first reply" + conv.send(listOf(Content.Text("first user"))) + // первый turn: WM = [user, assistant] + assertEquals(2, sqliteStores.workingMemory.list(conv.id).size) + + fakeLlm.reply = "second reply" + conv.send(listOf(Content.Text("second user"))) + // второй turn: WM должен вырасти до [user, assistant, user, assistant] + val wm = sqliteStores.workingMemory.list(conv.id) + System.err.println("[TEST] wm.size=${wm.size}") + wm.forEachIndexed { i, row -> System.err.println("[TEST] $i: ${row.entry::class.simpleName} id=${row.id}") } + assertEquals(4, wm.size) + // Новая семантика: один LiteConv на turn → два LiteConv после двух send'ов. + assertEquals(2, fakeLlm.conversations.size) + // Второй LiteConv создан с initialMessages из working memory, ИСКЛЮЧАЯ pending user2 + // (он передаётся в sendStreamContents, чтобы не дублироваться). + val reopened = fakeLlm.conversations.last() + assertEquals(2, reopened.initialMessages.size) + assertEquals("first user", reopened.initialMessages[0].text) + assertEquals(LiteRole.USER, reopened.initialMessages[0].role) + assertEquals("first reply", reopened.initialMessages[1].text) + assertEquals(LiteRole.MODEL, reopened.initialMessages[1].role) + // После sendStreamContents (с user2 + сгенерированный asst2) mutableHistory = 4 + assertEquals(4, reopened.history.size) + assertEquals("second reply", reopened.history.last().text) + assertEquals(LiteRole.MODEL, reopened.history.last().role) + } + + @Test + fun `reloaded conversation reconstructs LiteConversation from working memory`() = runTest { + val agent = newAgent() + fakeLlm.rememberHistory = true + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "first reply" + conv.send(listOf(Content.Text("first user"))) + val convId = conv.id + conv.close() + + // Открываем новое ChatConversation с тем же id — LiteConversation должен + // быть создан заново из working memory (первый user+assistant как initial). + val reopened = agent.getConversation(convId)!! + fakeLlm.reply = "second reply" + reopened.send(listOf(Content.Text("second user"))) + + val allConvs = fakeLlm.conversations + assertEquals(2, allConvs.size) // original + reopened + val reopenedLite = allConvs.last() + // Initial messages: только прошлые user+assistant (НЕ включая текущий "second user") + assertEquals(2, reopenedLite.initialMessages.size) + assertEquals("first user", reopenedLite.initialMessages[0].text) + assertEquals(LiteRole.USER, reopenedLite.initialMessages[0].role) + assertEquals("first reply", reopenedLite.initialMessages[1].text) + assertEquals(LiteRole.MODEL, reopenedLite.initialMessages[1].role) + } + + @Test + fun `LLM failure emits error event and persists error message`() = runTest { + val agent = newAgent() + fakeLlm.failMessage = "boom from llm" + val conv = agent.createConversation(temp = false) as ChatConversation + + val events = mutableListOf() + val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + conv.events(Instant.DISTANT_PAST).collect { events.add(it) } + } + conv.send(listOf(Content.Text("hi"))) + delay(50) + job.cancel() + + assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events") + assertTrue(events.any { it is ProtoEvent.End }, "events=$events") + + val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() + assertEquals(2, msgs.size) + assertIs(msgs[0]) + val err = assertIs(msgs[1]) + assertEquals("boom from llm", err.message) + + // backfill через getMessages (polling/reconnect) тоже видит ошибку + val proto = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = 10) + assertTrue( + proto.any { it is pw.binom.agentik.proto.Message.Error && it.message == "boom from llm" }, + "history=$proto", + ) + } + + @Test + fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest { + // Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel() + // бросает CancellationException в стриме, runTurn выходит через finally. + // Если turn не успел ничего сгенерить (reply.isEmpty() && toolExchanges.isEmpty()) + // — AssistantMessage в audit log НЕ пишется. Только user + End/Interrupted. + val agent = newAgent() + fakeLlm.slow = true + val conv = agent.createConversation(temp = false) + + // Подписываемся на events ДО send() — SharedFlow без replay, после + // отправки событий подписка ничего не увидит. + val events = mutableListOf() + val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + conv.events(Instant.DISTANT_PAST).collect { events.add(it) } + } + + val sendJob = launch { + try { + conv.send(listOf(Content.Text("hi"))) + } catch (_: kotlinx.coroutines.CancellationException) { + // ok + } + } + // ждём, пока корутина дойдёт до sendStreamContents и повиснет на slow-эмиссии + delay(200) + conv.interrupt() + sendJob.join() + eventsJob.cancel() + + // audit: только user (assistant не успел сгенериться) + val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() + assertEquals(1, msgs.size) + assertIs(msgs[0]) + + // working memory: только user (assistant skipped because пустой) + val wm = sqliteStores.workingMemory.list(conv.id) + assertEquals(1, wm.size) + assertTrue(wm[0].entry is WorkingMemoryEntry.User) + + // events: должны включать Interrupted + End + assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events") + assertTrue(events.any { it is ProtoEvent.End }, "events=$events") + } + + @Test + fun `interrupt after tool execution preserves tool result in working memory`() = runTest { + // Сценарий "LLM вызвал тул, инструмент выполнился, потом interrupt()": + // 1. LLM скриптован на ToolCalls([echo_tool]) + // 2. Tool реально вызывается через toolsetDispatch.dispatch() + // 3. interrupt() приходит в окне между финальным text и завершением turn'а + // + // В audit log: user + ToolCall + ToolResult (инструмент выполнился). + // В working_memory: user + ToolExchange(result=echo output, wasCancelled=false). + // В events: ToolCall + ToolResult + Interrupted + End. + val agent = newAgent() + val conv = agent.createConversation(temp = false) + + // LLM скриптован: tool call. + fakeLlm.scriptedReplies = mutableListOf( + FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))), + ) + + val echoTool = object : LiteTool { + override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}""" + override fun invoke(arguments: String): String = """{"echo":$arguments}""" + } + agent.registerToolForTest("echo_tool", echoTool) + + // Подписываемся ДО send — SharedFlow без replay + val events = mutableListOf() + val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + conv.events(Instant.DISTANT_PAST).collect { events.add(it) } + } + + val sendJob = launch { + try { + conv.send(listOf(Content.Text("run echo tool"))) + } catch (_: kotlinx.coroutines.CancellationException) {} + } + // Ждём пока инструмент выполнится (turn завершится нормально) + sendJob.join() + // interrupt() ПОСЛЕ завершения turn — не должно ничего менять в БД, + // но проверяем что events включает все ожидаемые типы. + conv.interrupt() + eventsJob.cancel() + + // audit: user + toolcall + toolresult (tool выполнился), assistant может быть + val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() + val toolResult = msgs.filterIsInstance().firstOrNull() + assertNotNull(toolResult, "tool result должен быть в audit — tool выполнился нормально") + val toolResultResult = toolResult!!.result!! + assertTrue(toolResultResult.contains("echo"), "tool result содержит реальный ответ тулы: $toolResultResult") + + // working memory: user + tool_exchange + val wm = sqliteStores.workingMemory.list(conv.id) + val exchanges = wm.mapNotNull { (it.entry as? WorkingMemoryEntry.ToolExchange) } + assertEquals(1, exchanges.size) + assertEquals("echo_tool", exchanges[0].toolName) + assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён") + assertTrue(exchanges[0].resultText.contains("echo")) + + // events должны включать ToolCall + ToolResult. End — обязательно (turn завершился). + assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events") + assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events") + assertTrue(events.any { it is ProtoEvent.End }, "events=$events") + } + + @Test + fun `temp conversation is not persisted across agent instances`() = runTest { + // Поднимаем file-backed БД, создаём temp-беседу + sqliteStores.close() + val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db") + sqliteStores = KsqliteStores.open(dbPath) + val agent1 = ChatAgent( + id = "agentik", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = FakeLiteLlm().also { fakeLlm = it }, + llmConfig = LlmConfig( + backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + ) + val tempConv = agent1.createConversation(temp = true) + val tempId = tempConv.id + assertNotNull(agent1.getConversation(tempId)) + + // Переоткрываем БД — temp-беседа не должна пережить рестарт + sqliteStores.close() + sqliteStores = KsqliteStores.open(dbPath) + val agent2 = ChatAgent( + id = "agentik", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = fakeLlm, + llmConfig = LlmConfig( + backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + ) + assertNull(agent2.getConversation(tempId)) + java.io.File(dbPath).delete() + } + + @Test + fun `non-temp conversation persists across agent instances`() = runTest { + sqliteStores.close() + val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db") + sqliteStores = KsqliteStores.open(dbPath) + val agent1 = ChatAgent( + id = "agentik", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = FakeLiteLlm().also { fakeLlm = it }, + llmConfig = LlmConfig( + backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + ) + val conv = agent1.createConversation(temp = false) + val id = conv.id + + sqliteStores.close() + sqliteStores = KsqliteStores.open(dbPath) + val agent2 = ChatAgent( + id = "agentik", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = fakeLlm, + llmConfig = LlmConfig( + backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + ) + assertNotNull(agent2.getConversation(id)) + java.io.File(dbPath).delete() + } + + private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm { + override val backendName: String = "fake" + override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null) + override fun isInitialized(): Boolean = true + override fun createConversation(config: LiteConversationConfig): LiteConversation = + error("not used in reload test") + override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used") + override fun inferStream(request: pw.binom.litert.LiteRequest): Flow = error("not used") + override fun close() {} + } + + @Test + fun `tool-call loop executes registered tool and feeds result back`() = runTest { + val echoTool = object : LiteTool { + override fun describe(): String = """{"type":"function","function":{"name":"echo"}}""" + override fun invoke(arguments: String): String = "echoed: $arguments" + } + val toolLlm = ToolLoopFakeLiteLlm() + val agent = newAgent(llm = toolLlm, tools = listOf(NamedTool("echo", echoTool))) + val conv = agent.createConversation(temp = false) + + conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool"))) + + // sendStreamContents вызывается дважды: первый раз с user-сообщением + // (LLM отвечает tool_call), второй раз — после addToolResult — для + // триггера continuation у stateless-бэкендов (OpenAI). На этой fake + // LiteLlm оба попадают в счётчик. + assertEquals(2, toolLlm.toolCallCount, "expected user send + post-tool continuation") + assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult, + "expected echo tool invoked with the LLM's args, result fed back via addToolResult") + assertEquals("final reply", toolLlm.finalReplyEmitted, + "expected continuation send after tool result to emit final text") + + agent.close() + } + + @Test + fun `agentEvents - Created + Deleted flow`() = runTest { + val agent = newAgent() + val events = mutableListOf() + val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + // Agent.events() удалён из :proto — события живут в + // agent.outbox.agentEvents(): Flow; + // распаковываем .event для получения AgentEvent. + agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { events.add(it.event) } + } + val conv = agent.createConversation(temp = false) + agent.deleteConversation(conv.id) + delay(50) + job.cancel() + + assertEquals(2, events.size) + val created = events[0] as AgentEvent.Created + val deleted = events[1] as AgentEvent.Deleted + assertEquals(conv.id, created.conversationId) + assertEquals(conv.id, deleted.id) + } +} + +/** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */ +private class ToolLoopFakeLiteLlm : LiteLlm { + override val backendName: String = "fake-tool" + override val capabilities: pw.binom.litert.LiteCapabilities? = null + + var toolCallCount: Int = 0 + var lastToolResult: String? = null + var finalReplyEmitted: String? = null + + override fun isInitialized(): Boolean = true + + override fun createConversation(config: LiteConversationConfig): LiteConversation { + return object : LiteConversation { + private val hist = mutableListOf() + override val history: List get() = hist.toList() + override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt))) + override fun sendStreamContents(contents: List): Flow { + hist.add(LiteMessage(LiteRole.USER, contents)) + toolCallCount++ + return flowOf( + LiteDelta( + text = "", + isDone = true, + toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))), + ), + ) + } + override fun send(prompt: String): String = "unused" + override fun sendContents(contents: List): String = "unused" + override fun cancel() {} + override fun tokenCount(): Int = hist.size + override fun addToolResult(callId: String?, name: String, result: String): LiteDelta { + lastToolResult = result + val reply = "final reply" + finalReplyEmitted = reply + return LiteDelta(text = reply, isDone = true) + } + override fun close() {} + } + } + + override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used") + override fun inferStream(request: pw.binom.litert.LiteRequest): Flow = error("not used") + override fun close() {} +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt new file mode 100644 index 0000000..6121b6c --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt @@ -0,0 +1,200 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.standalone.llm.LlmConfig +import pw.binom.agentik.storage.ksqlite.KsqliteStores +import pw.binom.agentik.toolsets.ToolsetContribution +import pw.binom.litert.LiteLlm +import pw.binom.litert.LiteTool +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Интеграционные тесты ChatAgent + toolsets: проверяем что при пустом + * toolsets=List (дефолт) агент ведёт себя как раньше (нет enable/disable тулов, + * нет секции в system prompt), а при non-empty — добавляет их и регистрирует + * диспетчер. + */ +class ChatAgentToolsetsTest { + + private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool { + override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}""" + override fun invoke(arguments: String) = response + } + + private fun stubLlm(): LiteLlm = object : LiteLlm { + override val backendName: String = "stub" + override fun isInitialized(): Boolean = true + override fun createConversation(config: pw.binom.litert.LiteConversationConfig): pw.binom.litert.LiteConversation = + throw UnsupportedOperationException("not used in this test") + override fun infer(request: pw.binom.litert.LiteRequest): String = + throw UnsupportedOperationException("not used in this test") + override fun inferStream(request: pw.binom.litert.LiteRequest): kotlinx.coroutines.flow.Flow = + throw UnsupportedOperationException("not used in this test") + override val capabilities: pw.binom.litert.LiteCapabilities? = null + override fun close() {} + } + + private fun newAgent( + toolsets: List = emptyList(), + ): Pair { + val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}") + val agent = ChatAgent( + id = "test-agent", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = stubLlm(), + llmConfig = LlmConfig( + backend = pw.binom.agentik.standalone.llm.LlmBackend.GOOGLE, + systemPrompt = "base", + google = pw.binom.agentik.standalone.llm.GoogleConfig(modelPath = "/tmp/fake.gguf"), + ), + toolsets = toolsets, + ) + return agent to sqliteStores + } + + @Test + fun `default (empty toolsets) does not register enable or disable tools`() { + val (agent, storage) = newAgent() + try { + // Проверяем через allTools-эквивалент: вызываем enable_toolset + // и ожидаем что он не найден — это значит тул не зарегистрирован. + val conv = agent.createConversation(temp = true) + // system prompt не должен содержать секции Toolsets + val prompt = (conv as ChatConversation).let { it.systemPromptForTest() } + assertFalse(prompt.contains("## Toolsets"), "toolsets section must NOT appear when toolsets empty") + assertFalse(prompt.contains("enable_toolset"), "enable_toolset must NOT be mentioned when toolsets empty") + } finally { storage.close() } + } + + @Test + fun `non-empty toolsets registers enable and disable tools and includes section`() { + val toolsets = listOf( + ToolsetContribution( + name = "media", + description = "image and video processing", + tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))), + ), + ToolsetContribution( + name = "web", + description = "fetch and parse web pages", + tools = listOf(ToolsetContribution.ToolEntry("fetch_url", stubTool("fetch_url"))), + ), + ) + val (agent, storage) = newAgent(toolsets = toolsets) + try { + val conv = agent.createConversation(temp = true) as ChatConversation + val prompt = conv.systemPromptForTest() + assertTrue(prompt.contains("## Toolsets"), "toolsets section MUST appear when toolsets non-empty") + assertTrue(prompt.contains("- media — image and video processing")) + assertTrue(prompt.contains("- web — fetch and parse web pages")) + // Оба тула — в списке allTools (через system prompt проверяем только prompt; + // наличие тулов проверим отдельно — см. ниже). + } finally { storage.close() } + } + + @Test + fun `non-empty toolsets - enable_toolset activates and dispatches auto-included tools`() { + val mediaTool = stubTool("resize_image", "image-resized-100x100") + val toolsets = listOf( + ToolsetContribution( + name = "media", + description = "image and video processing", + tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)), + ), + ) + val (agent, storage) = newAgent(toolsets = toolsets) + try { + val conv = agent.createConversation(temp = true) as ChatConversation + + // Вызываем enable_toolset через диспетчер (минуя LLM, напрямую) + val enabled = runBlocking { + conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""") + } + assertEquals("Toolset 'media' activated.", enabled) + + // Теперь resize_image должен работать (тулсет активен) + val resized = runBlocking { + conv.dispatchToolForTest("resize_image", "{}") + } + assertEquals("image-resized-100x100", resized) + } finally { storage.close() } + } + + @Test + fun `non-empty toolsets - calling tool from inactive toolset triggers auto-activation`() { + val mediaTool = stubTool("resize_image", "auto-activated-and-ran") + val toolsets = listOf( + ToolsetContribution( + name = "media", + description = "x", + tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)), + ), + ) + val (agent, storage) = newAgent(toolsets = toolsets) + try { + val conv = agent.createConversation(temp = true) as ChatConversation + + // Без enable сразу вызываем resize_image — диспетчер должен auto-activate. + val result = runBlocking { + conv.dispatchToolForTest("resize_image", "{}") + } + assertEquals("auto-activated-and-ran", result) + } finally { storage.close() } + } + + @Test + fun `non-empty toolsets - disable_toolset removes from active list`() { + // Тест проверяет только сайд-эффект на реестре (active set), + // а не реальный dispatch — потому что диспетчер auto-activate'ит тулсет + // обратно при следующем вызове (это by design: если модель забыла что + // тулсет выключен, мы прощаем и включаем заново). + val toolsets = listOf( + ToolsetContribution( + name = "media", + description = "x", + tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))), + ), + ) + val (agent, storage) = newAgent(toolsets = toolsets) + try { + val conv = agent.createConversation(temp = true) as ChatConversation + + runBlocking { conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""") } + // disable + val disabled = runBlocking { + conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""") + } + assertEquals("Toolset 'media' deactivated.", disabled) + // После disable реестр уже не содержит media в active + // (проверяем косвенно: disable повторно всё ещё возвращает тот же uniform message) + val disabledAgain = runBlocking { + conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""") + } + assertEquals("Toolset 'media' deactivated.", disabledAgain) + } finally { storage.close() } + } +} + +// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов. +internal fun ChatConversation.systemPromptForTest(): String { + // Через рефлексию достаём private val systemPrompt. + val f = this::class.java.getDeclaredField("systemPrompt").apply { isAccessible = true } + return f.get(this) as String +} + +internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String { + // Через toolsetDispatch (если есть) или прямой toolsByName. + val dispatchField = this::class.java.declaredFields.first { it.name == "toolsetDispatch" }.apply { isAccessible = true } + val dispatch = dispatchField.get(this) ?: error("toolsetDispatch must be set when toolsets present") + val outcome = (dispatch as pw.binom.agentik.toolsets.ToolsetDispatchPolicy).dispatch(toolName, argsJson) + return when (outcome) { + is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Ran -> outcome.result + is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: $toolName]" + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt new file mode 100644 index 0000000..70b63a3 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt @@ -0,0 +1,186 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemorySource +import pw.binom.agentik.memory.MemoryStore +import pw.binom.agentik.memory.NewMemoryNote +import pw.binom.agentik.memory.ReviewedTurn +import pw.binom.agentik.memory.md.KeywordMdReviewer +import pw.binom.agentik.standalone.llm.LlmBackend +import pw.binom.agentik.standalone.llm.LlmConfig +import pw.binom.agentik.standalone.llm.OpenAiConfig +import pw.binom.agentik.storage.ksqlite.KsqliteStores +import pw.binom.agentik.proto.Content as ProtoContent +import pw.binom.litert.LiteConversation +import pw.binom.litert.LiteConversationConfig +import pw.binom.litert.LiteLlm +import pw.binom.litert.LiteMessage +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.Flow +import pw.binom.agentik.memory.MemoryStoreEvent +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.assertFalse +import kotlin.time.Instant +import kotlinx.coroutines.flow.asSharedFlow +import pw.binom.agentik.llm.tools.ContextCompactor +import pw.binom.agentik.llm.tools.SummaryTurn + +/** + * Тесты для [ChatConversation.compactPreTurnIfNeeded]: триггер compaction'а + * при превышении порога, вызов суммаризатора, триггер memory review, и + * атомарный replace в working memory. + */ +class CompactionTest { + + private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores + private lateinit var fakeLlm: FakeLiteLlm + + @BeforeTest + fun setup() { + sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}") + fakeLlm = FakeLiteLlm() + } + + @AfterTest + fun tearDown() { + sqliteStores.close() + } + + private fun newAgent( + contextWindow: Int? = null, + compressionThreshold: Double = 0.8, + compactor: ContextCompactor? = null, + memoryStore: MemoryStore? = null, + ): ChatAgent { + val reviewer = if (memoryStore != null) KeywordMdReviewer() else null + return ChatAgent( + id = "test", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = fakeLlm, + llmConfig = LlmConfig( + backend = LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + memoryStore = memoryStore, + memoryReviewer = reviewer, + contextWindow = contextWindow, + compressionThreshold = compressionThreshold, + contextCompactor = compactor, + ) + } + + @Test + fun `compaction is no-op when contextWindow is null`() = runTest { + // contextWindow=null → даже с огромной историей compaction не запустится. + fakeLlm.reply = "hi" + val agent = newAgent(contextWindow = null, compactor = RecordingCompactor("summary")) + val conv = agent.createConversation(temp = false) as ChatConversation + repeat(10) { + conv.send(listOf(ProtoContent.Text("turn $it: ${"x".repeat(200)}"))) + } + val wm = sqliteStores.workingMemory.list(conv.id) + // Без compaction все ходы остаются в памяти (System + 10 user/assistant = 21 строк). + val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary } + assertEquals(0, summaries.size, "compaction must not run without contextWindow") + } + + @Test + fun `compaction is no-op when compactor is null but window is set`() = runTest { + fakeLlm.reply = "hi" + val agent = newAgent(contextWindow = 10, compactor = null) + val conv = agent.createConversation(temp = false) as ChatConversation + conv.send(listOf(ProtoContent.Text("first"))) + val wm = sqliteStores.workingMemory.list(conv.id) + // System + User + Assistant = 3. Без compactor — никаких Summary. + val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary } + assertEquals(0, summaries.size, "no compaction runs without compactor") + } + + @Test + fun `compaction triggers when estimated tokens exceed threshold`() = runTest { + fakeLlm.reply = "ok" + val compactor = RecordingCompactor("**Goal**: x\n**Active**: y\n**Resolved**: z") + // contextWindow = 20 chars → ~5 токенов. С порогом 0.5 (50%) — почти любой ход пробивает. + val agent = newAgent(contextWindow = 20, compressionThreshold = 0.5, compactor = compactor) + val conv = agent.createConversation(temp = false) as ChatConversation + + conv.send(listOf(ProtoContent.Text("user message one — long enough to cross threshold"))) + + // Compactor должен был быть вызван хотя бы раз. + assertTrue(compactor.calls > 0, "compactor must be called at least once when above threshold") + // В working memory должна появиться Summary. + val wm = sqliteStores.workingMemory.list(conv.id) + val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary } + assertTrue(summaries.isNotEmpty(), "at least one Summary entry should be present after compaction") + // Summary-текст — то, что вернул наш compactor. + val summaryText = (summaries.first().entry as pw.binom.agentik.context.WorkingMemoryEntry.Summary).text + assertTrue(summaryText.startsWith("**Goal**"), "summary text should come from compactor: $summaryText") + } + + @Test + fun `compaction calls memoryReviewer reviewPreCompaction`() = runTest { + fakeLlm.reply = "ok" + val memStore = TestInMemoryMemoryStore() + val compactor = RecordingCompactor("compacted summary") + val agent = newAgent( + contextWindow = 30, + compressionThreshold = 0.5, + compactor = compactor, + memoryStore = memStore, + ) + val conv = agent.createConversation(temp = false) as ChatConversation + + conv.send(listOf(ProtoContent.Text("Я обычно предпочитаю kotlin для бэкенда."))) + + // Память должна получить хотя бы одну заметку от reviewPreCompaction. + val notes = memStore.list() + assertTrue(notes.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("kotlin") }, + "memory should capture a preference fact before compaction drops the turn") + } + + @Test + fun `compaction preserves recent turns (KEEP_RECENT_TURNS)`() = runTest { + fakeLlm.reply = "ok" + val compactor = RecordingCompactor("compacted summary") + val agent = newAgent(contextWindow = 30, compressionThreshold = 0.3, compactor = compactor) + val conv = agent.createConversation(temp = false) as ChatConversation + + conv.send(listOf(ProtoContent.Text("first turn"))) + conv.send(listOf(ProtoContent.Text("second turn"))) + conv.send(listOf(ProtoContent.Text("third turn — long content ${"y".repeat(150)}"))) + + val wm = sqliteStores.workingMemory.list(conv.id) + // Должны быть: System + хотя бы один Summary + последние KEEP_RECENT_TURNS ходов. + // KEEP_RECENT_TURNS = 4 → user/assistant последних двух ходов (third + second) могут быть не тронуты. + val userAssistantCount = wm.count { + it.entry is pw.binom.agentik.context.WorkingMemoryEntry.User || + it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Assistant + } + // Минимум 1 ход остаётся (KEEP_RECENT_TURNS). + assertTrue(userAssistantCount >= 1, "at least one recent turn must be preserved") + } +} + +private class RecordingCompactor(private val result: String) : ContextCompactor { + var calls = 0 + override suspend fun summarize(turns: List): String { + calls++ + return result + } +} + +/** + * (InMemoryMemoryStore вынесен в [TestInMemoryMemoryStore].) + */ diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ContextPrefixTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ContextPrefixTest.kt new file mode 100644 index 0000000..95ceea2 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ContextPrefixTest.kt @@ -0,0 +1,116 @@ +package pw.binom.agentik.standalone.agent + +import pw.binom.agentik.journal.MessageContext +import pw.binom.agentik.journal.MessageOrigin.EVENT +import pw.binom.agentik.journal.MessageOrigin.SYSTEM +import pw.binom.agentik.journal.MessageOrigin.USER +import pw.binom.litert.LiteContentPart +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * Тесты для префикса контекста инициации хода в user-сообщениях. + * Только для не-USER origin'ов. USER — без изменений. + */ +class ContextPrefixTest { + + @Test + fun `USER origin produces no prefix`() { + val ctx = MessageContext(origin = USER) + val parts = listOf(LiteContentPart.Text("hello")) + val out = applyContextPrefix(parts, ctx) + assertEquals(parts, out, "USER should not modify content") + } + + @Test + fun `null context produces no prefix`() { + val parts = listOf(LiteContentPart.Text("hello")) + val out = applyContextPrefix(parts, null) + assertEquals(parts, out) + } + + @Test + fun `SYSTEM origin prepends label to first text part`() { + val ctx = MessageContext(origin = SYSTEM, description = "agent startup greeting") + val parts = listOf(LiteContentPart.Text("boot")) + val out = applyContextPrefix(parts, ctx) + assertEquals(1, out.size) + val text = assertIs(out[0]) + assertEquals("[SYSTEM] agent startup greeting\nboot", text.text) + } + + @Test + fun `EVENT origin with sourceId includes it`() { + val ctx = MessageContext( + origin = EVENT, + description = "scheduled cron morning-briefing", + sourceId = "cron-42", + ) + val parts = listOf(LiteContentPart.Text("wake up")) + val out = applyContextPrefix(parts, ctx) + val text = assertIs(out[0]) + assertEquals("[EVENT] scheduled cron morning-briefing (sourceId=cron-42)\nwake up", text.text) + } + + @Test + fun `prefix only added to first text part, others untouched`() { + val ctx = MessageContext(origin = EVENT, description = "test") + val parts = listOf( + LiteContentPart.Text("first"), + LiteContentPart.Text("second"), + ) + val out = applyContextPrefix(parts, ctx) + assertEquals(2, out.size) + val first = assertIs(out[0]) + val second = assertIs(out[1]) + assertEquals("[EVENT] test\nfirst", first.text) + assertEquals("second", second.text) + } + + @Test + fun `prefix with no text parts is prepended as standalone text`() { + val ctx = MessageContext(origin = SYSTEM, description = "ping") + // Симулируем: модель получает картинку + контекст — контекст идёт первой Text-частью. + val parts = listOf(LiteContentPart.Text("just prefix")) + val out = applyContextPrefix(parts, ctx) + assertEquals(1, out.size) + val text = assertIs(out[0]) + assertEquals("[SYSTEM] ping\njust prefix", text.text) + } + + @Test + fun `formatContextPrefix formats name + description + sourceId`() { + val ctx = MessageContext(origin = EVENT, description = "wake", sourceId = "cron-1") + assertEquals("[EVENT] wake (sourceId=cron-1)", formatContextPrefix(ctx)) + } + + @Test + fun `formatContextPrefix omits blank description and sourceId`() { + val ctx = MessageContext(origin = SYSTEM) + assertEquals("[SYSTEM]", formatContextPrefix(ctx)) + } + + @Test + fun `formatContextPrefix omits blank sourceId even if description is set`() { + val ctx = MessageContext(origin = SYSTEM, description = "boot", sourceId = "") + assertEquals("[SYSTEM] boot", formatContextPrefix(ctx)) + } + + @Test + fun `USER origin with context fields still produces no prefix`() { + // Контекст с USER-происхождением, но с заполненным description/sourceId: + // не должен триггерить префикс (UI-метаданные для логирования). + val ctx = MessageContext(origin = USER, sourceId = "irc:agentik", description = "PRIVMSG") + val parts = listOf(LiteContentPart.Text("hi")) + val out = applyContextPrefix(parts, ctx) + assertEquals(parts, out) + } + + // Вспомогательное для теста + @Test + fun `null-context assert helper`() { + assertNull(null as String?) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt new file mode 100644 index 0000000..6267b00 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt @@ -0,0 +1,120 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import pw.binom.litert.LiteContentPart +import pw.binom.litert.LiteConversation +import pw.binom.litert.LiteConversationConfig +import pw.binom.litert.LiteDelta +import pw.binom.litert.LiteLlm +import pw.binom.litert.LiteMessage +import pw.binom.litert.LiteRole +import pw.binom.litert.LiteToolCall + +/** + * Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая + * заданной строкой [reply] двумя фрагментами + done. + */ +internal class FakeLiteLlm : LiteLlm { + sealed class Reply { + data class Text(val text: String) : Reply() + data class ToolCalls(val calls: List>>) : Reply() + } + + override val backendName: String = "fake" + override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null) + var reply: String = "" + var rememberHistory: Boolean = false + var slow: Boolean = false + var failMessage: String? = null + + /** + * Если задан, LLM проходит по этому списку ответов по порядку: первый + * sendStreamContents → первый Reply, второй → второй и т.д. Если список + * кончился — fallback на [reply] (text). + */ + var scriptedReplies: MutableList = mutableListOf() + + var lastConfig: LiteConversationConfig? = null + var lastContents: List? = null + val conversations = mutableListOf() + + override fun isInitialized(): Boolean = true + + override fun createConversation(config: LiteConversationConfig): LiteConversation { + lastConfig = config + val conv = FakeLiteConversation(this, config) + conversations.add(conv) + return conv + } + + override fun infer(request: pw.binom.litert.LiteRequest): String = + throw UnsupportedOperationException("not used in test") + + override fun inferStream(request: pw.binom.litert.LiteRequest): Flow = + throw UnsupportedOperationException("not used in test") + + override fun close() {} + + fun nextReply(): Reply = + if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply) +} + +internal class FakeLiteConversation( + private val parent: FakeLiteLlm, + config: LiteConversationConfig, +) : LiteConversation { + val initialMessages: List = config.initialMessages + private val mutableHistory: MutableList = config.initialMessages.toMutableList() + override val history: List get() = mutableHistory.toList() + + override fun sendStream(prompt: String): Flow = + sendStreamContents(listOf(LiteContentPart.Text(prompt))) + + override fun sendStreamContents(contents: List): Flow { + parent.lastContents = contents + parent.failMessage?.let { msg -> + return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) } + } + mutableHistory.add(LiteMessage(LiteRole.USER, contents)) + val next = parent.nextReply() + return when (next) { + is FakeLiteLlm.Reply.Text -> { + if (parent.slow) { + kotlinx.coroutines.flow.flow { + emit(LiteDelta(text = next.text.substring(0, next.text.length / 2))) + kotlinx.coroutines.delay(10_000) + emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true)) + mutableHistory.add(LiteMessage.model(next.text)) + } + } else { + val first = next.text.substring(0, next.text.length / 2) + val second = next.text.substring(next.text.length / 2) + flowOf( + LiteDelta(text = first), + LiteDelta(text = second, isDone = true), + ).also { mutableHistory.add(LiteMessage.model(next.text)) } + } + } + is FakeLiteLlm.Reply.ToolCalls -> { + val calls = next.calls.map { (name, args) -> + LiteToolCall(name = name, arguments = args) + } + flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true)) + } + } + } + override fun send(prompt: String): String { + parent.lastContents = listOf(LiteContentPart.Text(prompt)) + return parent.reply + } + override fun sendContents(contents: List): String { + parent.lastContents = contents + return parent.reply + } + override fun cancel() {} + override fun tokenCount(): Int = history.size + override fun addToolResult(callId: String?, name: String, result: String): LiteDelta = + LiteDelta(text = "", isDone = true) + override fun close() {} +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt new file mode 100644 index 0000000..680bdbb --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt @@ -0,0 +1,348 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.files.SystemTemporaryDirectory +import pw.binom.agentik.memory.ConversationTurn +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemoryPrefetcher +import pw.binom.agentik.memory.MemoryReviewDecision +import pw.binom.agentik.memory.MemoryReviewer +import pw.binom.agentik.memory.MemorySearchQuery +import pw.binom.agentik.memory.MemorySearchResult +import pw.binom.agentik.memory.MemoryStore +import pw.binom.agentik.memory.MemorySource +import pw.binom.agentik.memory.MemorySystemGuidance +import pw.binom.agentik.memory.NewMemoryNote +import pw.binom.agentik.memory.ReviewedTurn +import pw.binom.agentik.memory.md.openMdMemorySystem +import pw.binom.agentik.proto.Content +import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory +import pw.binom.agentik.standalone.llm.LlmBackend +import pw.binom.agentik.standalone.llm.LlmConfig +import pw.binom.agentik.standalone.llm.OpenAiConfig +import pw.binom.agentik.storage.ksqlite.KsqliteStores +import pw.binom.agentik.llm.tools.ContextCompactor +import pw.binom.agentik.llm.tools.SummaryTurn +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Интеграция памяти в :standalone: + * - тулы memory_save/read/list/delete регистрируются у агента + * - prefetcher вставляет контекст в первое user-сообщение + * - reviewer пишет факты в store после хода + */ +class MemoryWiringTest { + + private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores + private lateinit var fakeLlm: FakeLiteLlm + private lateinit var root: Path + + @BeforeTest + fun setup() { + sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}") + fakeLlm = FakeLiteLlm() + root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}") + SystemFileSystem.createDirectories(root, mustCreate = true) + } + + @AfterTest + fun tearDown() { + sqliteStores.close() + runCatching { SystemFileSystem.delete(root, mustExist = false) } + } + + private fun newAgent( + memoryStore: MemoryStore, + prefetcher: MemoryPrefetcher, + reviewer: MemoryReviewer, + contextWindow: Int? = null, + compressionThreshold: Double = 0.8, + contextCompactor: ContextCompactor = EchoCompactor, + ): ChatAgent = ChatAgent( + id = "agentik", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = fakeLlm, + llmConfig = LlmConfig( + backend = LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + memoryStore = memoryStore, + memoryPrefetcher = prefetcher, + memoryReviewer = reviewer, + contextWindow = contextWindow, + compressionThreshold = compressionThreshold, + contextCompactor = contextCompactor, + ) + + @Test + fun `system prompt includes memory guidance when memory is enabled`() = runBlocking { + val system = openMdMemorySystem(root) + val agent = newAgent(system.store, system.prefetcher, system.reviewer) + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + // System prompt не пишется в working_memory — читаем то, что увидит LLM + val text = fakeLlm.lastConfig?.systemInstruction + assertNotNull(text) + assertTrue(text!!.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)), + "system prompt should contain MEMORY_GUIDANCE; got first 200 chars: ${text.take(200)}") + agent.close() + system.close() + } + + @Test + fun `soul body is prepended to system prompt and wins over base`() = runBlocking { + val soulBody = "I am a helpful test persona. I always answer in one short line." + val agent = ChatAgent( + id = "agentik", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = fakeLlm, + llmConfig = LlmConfig( + backend = LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + soulBody = soulBody, + ) + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + val text = fakeLlm.lastConfig?.systemInstruction + assertNotNull(text) + assertTrue(text!!.startsWith(soulBody), + "soul should be the very first section; got first 60 chars: ${text.take(60)}") + assertTrue(text.contains("be brief"), + "base prompt should still follow the soul; got: $text") + agent.close() + } + + @Test + fun `soul body not added when null`() = runBlocking { + val agent = ChatAgent( + id = "agentik", + conversationStore = sqliteStores.conversations, + messageStore = sqliteStores.messages, + workingMemoryStore = sqliteStores.workingMemory, + reflectionStore = sqliteStores.reflections, + llm = fakeLlm, + llmConfig = LlmConfig( + backend = LlmBackend.OPENAI, + systemPrompt = "be brief", + openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), + ), + ) + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + val text = fakeLlm.lastConfig?.systemInstruction + assertNotNull(text) + assertTrue(text!!.startsWith("be brief"), + "without soul, prompt should start with base; got first 60 chars: ${text.take(60)}") + agent.close() + } + + @Test + fun `agent exposes memory tools when store is configured`() { + val system = openMdMemorySystem(root) + val tools = MemoryToolsFactory.create(system.store) + assertEquals(4, tools.size) + val names = tools.map { it.name }.toSet() + assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names) + // Каждый tool описывается валидной JSON-схемой: + for (t in tools) { + assertTrue(t.tool.describe().contains("\"description\""), "describe() for ${t.name}") + } + system.close() + } + + @Test + fun `memory_save tool round-trips a note through the store`() = runBlocking { + val system = openMdMemorySystem(root) + val tools = MemoryToolsFactory.create(system.store).associateBy { it.name } + val saveResult = tools.getValue("memory_save").tool.invoke( + """{"category":"preference","content":"prefers tabs over spaces"}""", + ) + assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult") + assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult") + + val listResult = tools.getValue("memory_list").tool.invoke("""{"limit":10}""") + assertTrue(listResult.contains("prefers tabs over spaces"), + "list returned: $listResult") + + val readResult = tools.getValue("memory_read").tool.invoke( + """{"query":"tabs","top_k":3}""", + ) + assertTrue(readResult.contains("prefers tabs over spaces"), + "read returned: $readResult") + + val deleteResult = tools.getValue("memory_delete").tool.invoke( + Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m -> + """{"id":"${m.groupValues[1]}"}""" + } ?: error("save did not return id"), + ) + assertTrue(deleteResult.contains("\"ok\":true"), "delete returned: $deleteResult") + system.close() + } + + @Test + fun `prefetch inserts memory context into first user message`() = runTest { + // Сидим факт в store. + val store = openMdMemorySystem(root).also { + it.store.upsert( + MemoryNote( + id = "mem-pre", + category = MemoryCategory.USER, + content = "User runs k3s on Debian", + createdAt = Instant.parse("2026-09-14T10:00:00Z"), + lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"), + useCount = 0, + source = MemorySource.AGENT_SAVE, + ), + ) + } + val prefetcher = StaticPrefetcher { q, k -> + store.store.search(MemorySearchQuery(query = q, topK = k, category = null)) + } + fakeLlm.reply = "ok" + + val agent = newAgent(store.store, prefetcher, NoopReviewer()) + val conv = agent.createConversation(temp = false) as ChatConversation + conv.send(listOf(Content.Text("what's my k3s setup?"))) + + val sentText = fakeLlm.lastContents?.filterIsInstance() + ?.joinToString("\n") { it.text } + assertNotNull(sentText) + assertTrue(sentText.startsWith("[Memory context"), + "user message should start with memory prefix, got: $sentText") + assertTrue(sentText.contains("User runs k3s on Debian"), + "user message should include the prefetched note, got: $sentText") + assertTrue(sentText.contains("what's my k3s setup?"), + "user message should still contain the original text after the prefix, got: $sentText") + agent.close() + store.close() + } + + @Test + fun `prefetch does not add prefix when no hits`() = runTest { + val store = openMdMemorySystem(root) + val prefetcher = StaticPrefetcher { _, _ -> emptyList() } + fakeLlm.reply = "ok" + + val agent = newAgent(store.store, prefetcher, NoopReviewer()) + val conv = agent.createConversation(temp = false) as ChatConversation + conv.send(listOf(Content.Text("hello"))) + + val sentText = fakeLlm.lastContents?.filterIsInstance() + ?.joinToString("\n") { it.text } + assertNotNull(sentText) + assertTrue(!sentText.startsWith("[Memory context"), + "user message should not start with prefix when no hits, got: $sentText") + assertTrue(sentText.contains("hello")) + agent.close() + store.close() + } + + @Test + fun `reviewer fires on compaction (event-driven), not on every turn`() = runTest { + // После перехода на event-driven: review fires ТОЛЬКО в CompactionCoordinator.compactPreTurn() + // (через reviewer.reviewPreCompaction()) — не на каждый turn, не по interval'у. + // На простом turn без compaction review НЕ запускается. + val store = openMdMemorySystem(root) + fakeLlm.reply = "Sure, I'll remember that." + val reviewerInvoked = CompletableDeferred() + val reviewer = object : MemoryReviewer { + override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = + error("review() не должен вызываться — только reviewPreCompaction() на compaction") + override suspend fun reviewPreCompaction(turns: List): MemoryReviewDecision { + reviewerInvoked.complete(Unit) + return MemoryReviewDecision( + toSave = listOf(NewMemoryNote(MemoryCategory.PREFERENCE, "prefers k8s")), + toDelete = emptyList(), + ) + } + } + + val agent = newAgent( + store.store, + StaticPrefetcher { _, _ -> emptyList() }, + reviewer, + contextWindow = 1024, // forceCompactNow требует contextWindow + ) + val conv = agent.createConversation(temp = false) as ChatConversation + + // Отправляем turn — ничего не должно триггериться, т.к. event-driven review + // fires только на compaction/closing. + conv.send(listOf(Content.Text("please note: I prefer k8s over docker swarm"))) + + // Ждём немного, проверяем что reviewer.review() НЕ был вызван + withContext(Dispatchers.Default.limitedParallelism(1)) { + delay(200) + } + assertTrue(store.store.list(category = MemoryCategory.PREFERENCE).isEmpty(), + "review НЕ должен был вызваться на простом turn без compaction") + + // Триггерим compaction вручную (через debug-API ConversationLoop.forceCompactNow). + // Это вызывает CompactionCoordinator.compactPreTurn → reviewer.reviewPreCompaction. + conv.forceCompactNow() + + // Дожидаемся reviewPreCompaction и upsert'а в IO-диспетчере. + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(2_000) { reviewerInvoked.await() } + withTimeout(2_000) { + while (store.store.list(category = MemoryCategory.PREFERENCE).isEmpty()) delay(20) + } + } + + val notes = store.store.list(category = MemoryCategory.PREFERENCE) + assertEquals(1, notes.size, "reviewPreCompaction должен сохранить заметку") + assertEquals("prefers k8s", notes[0].content) + assertEquals(MemorySource.AUTO_REVIEW, notes[0].source) + agent.close() + store.close() + } +} + +// --- helpers --- + +private class StaticPrefetcher( + private val fn: suspend (String, Int) -> List, +) : MemoryPrefetcher { + override suspend fun prefetch(query: String, topK: Int, category: MemoryCategory?): List { + if (query.isBlank()) return emptyList() + return fn(query, topK).map { it.note } + } +} + +private class NoopReviewer : MemoryReviewer { + override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = + MemoryReviewDecision(toSave = emptyList(), toDelete = emptyList()) +} + +/** Простой compactor для тестов: возвращает статичную строку. */ +private object EchoCompactor : ContextCompactor { + override suspend fun summarize(turns: List): String = + if (turns.isEmpty()) "" else "compacted-${turns.size}-turns" +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt new file mode 100644 index 0000000..001ccf4 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt @@ -0,0 +1,71 @@ +package pw.binom.agentik.standalone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import pw.binom.agentik.llm.tools.ReflectionParser + +class ReflectionParserTest { + + @Test + fun `parses clean JSON`() { + val raw = """{"score": 4, "summary": "ok", "weakSpots": ["a", "b"]}""" + val p = ReflectionParser.parse(raw) + assertNotNull(p) + assertEquals(4, p.score) + assertEquals("ok", p.summary) + assertEquals(listOf("a", "b"), p.weakSpots) + } + + @Test + fun `parses JSON wrapped in json fences`() { + val raw = "```json\n" + + "{\"score\": 3, \"summary\": \"norm\", \"weakSpots\": []}\n" + + "```" + val p = ReflectionParser.parse(raw) + assertNotNull(p) + assertEquals(3, p.score) + assertEquals(listOf(), p.weakSpots) + } + + @Test + fun `parses JSON with leading and trailing text`() { + val raw = "Вот мой ответ:\n" + + "{\"score\": 2, \"summary\": \"плохо\", \"weakSpots\": [\"путаю\", \"медленно\"]}\n" + + "Конец." + val p = ReflectionParser.parse(raw) + assertNotNull(p) + assertEquals(2, p.score) + assertEquals(listOf("путаю", "медленно"), p.weakSpots) + } + + @Test + fun `accepts score as string`() { + val raw = """{"score": "5", "summary": "ok", "weakSpots": []}""" + val p = ReflectionParser.parse(raw) + assertNotNull(p) + assertEquals(5, p.score) + } + + @Test + fun `returns null on missing score`() { + val raw = """{"summary": "x", "weakSpots": []}""" + assertNull(ReflectionParser.parse(raw)) + } + + @Test + fun `returns null on invalid JSON`() { + assertNull(ReflectionParser.parse("not even json")) + } + + @Test + fun `handles escape sequences in weakSpots`() { + // raw содержит 4 backslashes подряд; парсер \\ → \, итого 2 backslashes в результате + val raw = """{"score": 3, "summary": "ok", "weakSpots": ["path\\\\file"]}""" + val p = ReflectionParser.parse(raw) + assertNotNull(p) + // парсер снимает один escape: \\\\ → \\ + assertEquals(listOf("path\\\\file"), p.weakSpots) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt new file mode 100644 index 0000000..2ebcc38 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt @@ -0,0 +1,66 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.memory.ConversationTurn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import pw.binom.agentik.llm.tools.SkillMiner + +class SkillMinerTest { + + private fun turns(n: Int): List = (1..n).map { + ConversationTurn(userMessage = "q$it", assistantMessage = "a$it") + } + + @Test + fun `mine returns parsed skills from model JSON reply`() = runBlocking { + val llm = FakeLiteLlm() + llm.reply = """{"skills": [{"name": "n", "description": "d", "body": "b"}]}""" + val miner = SkillMiner(llm, maxTurns = 10, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) + val out = miner.mine(turns(5), existing = emptyList()) + assertEquals(1, out.size) + assertEquals("n", out[0].name) + } + + @Test + fun `mine with empty reply returns empty`() = runBlocking { + val llm = FakeLiteLlm() + llm.reply = "{}" + val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) + assertTrue(miner.mine(turns(5), emptyList()).isEmpty()) + } + + @Test + fun `miner failure degrades to empty list`() = runBlocking { + val llm = FakeLiteLlm() + llm.failMessage = "onnx died" + val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) + assertTrue(miner.mine(turns(5), emptyList()).isEmpty()) + } + + @Test + fun `miner respects maxTurns`() = runBlocking { + val llm = FakeLiteLlm() + llm.reply = """{"skills": []}""" + val miner = SkillMiner(llm, maxTurns = 2, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) + miner.mine(turns(30), emptyList()) + val prompt = llm.lastContents!!.first().let { + val p = it as pw.binom.litert.LiteContentPart.Text + p.text + } + // В промпт попало только последние 2 хода из 30. + assertTrue(prompt.contains("q29"), "last turns missing: $prompt") + assertTrue(!prompt.contains("q1\n"), "old turn leaked: $prompt") + } + + @Test + fun `empty turns short-circuit without LLM call`() = runBlocking { + val llm = FakeLiteLlm() + llm.failMessage = "should not be called" + val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) + val out = miner.mine(emptyList(), emptyList()) + assertTrue(out.isEmpty()) + assertTrue(llm.conversations.isEmpty(), "LLM must not be called for empty input") + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt new file mode 100644 index 0000000..8a20474 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt @@ -0,0 +1,82 @@ +package pw.binom.agentik.standalone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import pw.binom.agentik.llm.tools.SkillMiningParser + +class SkillMiningParserTest { + + @Test + fun `parses clean JSON`() { + val raw = """{"skills": [{"name": "backend:spring:db", "description": "x", "body": "# step 1"}]}""" + val out = SkillMiningParser.parse(raw) + assertEquals(1, out.size) + assertEquals("backend:spring:db", out[0].name) + assertEquals("x", out[0].description) + assertEquals("# step 1", out[0].body) + } + + @Test + fun `parses JSON wrapped in fences with prose around`() { + val raw = "Окей, вот что я нашёл:\n```json\n" + + "{\"skills\": [{\"name\": \"a\", \"description\": \"d\", \"body\": \"b\"}]}\n" + + "```\nНадеюсь, помогло." + val out = SkillMiningParser.parse(raw) + assertEquals(1, out.size) + assertEquals("a", out[0].name) + } + + @Test + fun `parses bare array without skills key`() { + val raw = """[{"name": "x", "description": "d", "body": "b"}, {"name": "y"}]""" + val out = SkillMiningParser.parse(raw) + assertEquals(2, out.size) + assertEquals("x", out[0].name) + assertEquals("", out[1].body) + } + + @Test + fun `unescapes newlines and quotes in body`() { + val raw = "{\"skills\": [{\"name\": \"n\", \"description\": \"\", \"body\": \"line1\\nline2\\n\\nwith \\\"quotes\\\" and backslash \\\\\\\"\"}]}" + val out = SkillMiningParser.parse(raw) + assertEquals(1, out.size) + val body = out[0].body + assertTrue(body.contains("line1\nline2"), "body: $body") + assertTrue(body.contains("\"quotes\""), "body: $body") + } + + @Test + fun `empty skills array returns empty list`() { + val out = SkillMiningParser.parse("""{"skills": []}""") + assertTrue(out.isEmpty()) + } + + @Test + fun `model chatter with no JSON returns empty`() { + val out = SkillMiningParser.parse("Скилов не нашёл, всё чисто.") + assertTrue(out.isEmpty()) + } + + @Test + fun `truncated JSON returns empty`() { + val out = SkillMiningParser.parse("""{"skills": [{"name": "a", "description": "d", "body": """"") + assertTrue(out.isEmpty()) + } + + @Test + fun `skills without name are dropped`() { + val raw = """{"skills": [{"description": "no name"}, {"name": "ok"}]}""" + val out = SkillMiningParser.parse(raw) + assertEquals(1, out.size) + assertEquals("ok", out[0].name) + } + + @Test + fun `nested braces inside strings do not break balance`() { + val raw = """{"skills": [{"name": "n", "description": "d", "body": "echo '{\"k\": 1}'"}]}""" + val out = SkillMiningParser.parse(raw) + assertEquals(1, out.size) + assertEquals("echo '{\"k\": 1}'", out[0].body) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt new file mode 100644 index 0000000..8e66da7 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt @@ -0,0 +1,65 @@ +package pw.binom.agentik.standalone.agent + +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SkillReadToolTest { + + private val catalog = SkillCatalog( + listOf( + SkillFile(name = "lint", description = "lint things", body = "# Lint\nRun the linter."), + SkillFile(name = "empty", description = "no body", body = ""), + ), + ) + private val tool = SkillReadTool(catalog) + + @Test + fun describeIsFlatOpenApiSchema() { + val json = tool.describe() + // Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне. + assertTrue("\"name\"" in json) + assertTrue(SkillReadTool.NAME in json) + assertTrue("parameters" in json) + assertTrue(!("\"type\":\"function\"" in json || "\"type\": \"function\"" in json)) + } + + @Test + fun invokeReturnsBodyForKnownSkill() { + val result = tool.invoke("""{"name":"lint"}""") + assertEquals("# Lint\nRun the linter.", result) + } + + @Test + fun invokeUnknownSkillListsAvailable() { + val result = tool.invoke("""{"name":"nope"}""") + assertTrue("unknown skill" in result) + assertTrue("lint" in result) + assertTrue("empty" in result) + } + + @Test + fun invokeEmptyBodyGivesPlaceholder() { + val result = tool.invoke("""{"name":"empty"}""") + assertTrue("empty body" in result) + } + + @Test + fun invokeMissingNameIsError() { + val result = tool.invoke("{}") + assertTrue("[tool error]" in result) + assertTrue("name" in result) + } + + @Test + fun invokeInvalidJsonIsError() { + assertTrue("[tool error]" in tool.invoke("not json")) + } + + @Test + fun invokeBlankArgumentsIsError() { + assertTrue("[tool error]" in tool.invoke("")) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt new file mode 100644 index 0000000..d7f21d4 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt @@ -0,0 +1,98 @@ +package pw.binom.agentik.standalone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import java.io.File +import kotlin.uuid.Uuid +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.skills.DiskSkillStore +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillFile +import pw.binom.agentik.skills.SkillStore + +class SkillToolsTest { + + @Test + fun `SkillSaveTool persists and surfaces in catalog`() { + val dir = tempSkillDir() + val store: SkillStore = DiskSkillStore(dir) + val tool = SkillSaveTool(store) + + val arguments = """{"name":"my-skill","description":"Test skill","body":"# Hello"}""" + val result = tool.invoke(arguments) + assertTrue(result.contains("\"ok\":true"), "expected success, got: $result") + assertTrue(result.contains("my-skill")) + + runBlocking { + val reloaded = SkillCatalog(store.catalog.skills) + assertEquals(1, reloaded.skills.size) + val s = reloaded.skills.first() + assertEquals("my-skill", s.name) + assertEquals("Test skill", s.description) + assertEquals("# Hello", s.body) + } + + // Должен появиться файл на диске + assertTrue(File(dir, "my-skill/SKILL.md").exists()) + } + + @Test + fun `SkillSaveTool rejects blank name`() { + val dir = tempSkillDir() + val store = DiskSkillStore(dir) + val tool = SkillSaveTool(store) + val arguments = """{"name":"","description":"x","body":"y"}""" + // Ожидаем ошибку + try { + tool.invoke(arguments) + error("should have thrown") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("skill name", ignoreCase = true)) + } + } + + @Test + fun `SkillDeleteTool archives the skill`() { + val dir = tempSkillDir() + val store = DiskSkillStore(dir) + store.upsert(SkillFile(name = "to-delete", description = "x", body = "y")) + + val tool = SkillDeleteTool(store) + val result = tool.invoke("""{"name":"to-delete"}""") + assertTrue(result.contains("\"ok\":true")) + assertTrue(result.contains("to-delete")) + + // Скил исчез из каталога + assertTrue(store.catalog.skills.none { it.name == "to-delete" }) + // Файл переименован в .archived + assertTrue(File(dir, "to-delete/SKILL.md.archived").exists()) + } + + @Test + fun `SkillDeleteTool returns error for missing skill`() { + val dir = tempSkillDir() + val store = DiskSkillStore(dir) + val tool = SkillDeleteTool(store) + try { + tool.invoke("""{"name":"does-not-exist"}""") + error("should have thrown") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("not found", ignoreCase = true)) + } + } + + @Test + fun `colon-named skills map to nested directories`() { + val dir = tempSkillDir() + val store = DiskSkillStore(dir) + store.upsert(SkillFile(name = "backend:spring:db-base", description = "x", body = "y")) + assertTrue(File(dir, "backend/spring/db-base/SKILL.md").exists()) + } + + private fun tempSkillDir(): File { + val dir = File(System.getProperty("java.io.tmpdir"), "agentik-skills-${Uuid.random()}") + dir.deleteOnExit() + return dir + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/TestMemoryStores.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/TestMemoryStores.kt new file mode 100644 index 0000000..97c83d4 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/TestMemoryStores.kt @@ -0,0 +1,55 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemorySearchQuery +import pw.binom.agentik.memory.MemorySearchResult +import pw.binom.agentik.memory.MemoryStore +import pw.binom.agentik.memory.MemoryStoreEvent +import kotlin.time.Instant + +/** + * Простой in-memory MemoryStore для тестов. + * + * Не зависит от :memory-md / :memory-vector; идёт через `archiveStale` из + * default-имплементации MemoryStore. + */ +class TestInMemoryMemoryStore : MemoryStore { + private val notes = mutableMapOf() + private val ev = MutableSharedFlow(extraBufferCapacity = 16) + + override suspend fun upsert(note: MemoryNote) { + notes[note.id] = note + ev.tryEmit(MemoryStoreEvent.Upserted(note)) + } + override suspend fun get(id: String): MemoryNote? = notes[id] + override suspend fun list( + category: MemoryCategory?, + conversationId: String?, + limit: Int, + offset: Int, + ): List = + notes.values + .filter { category == null || it.category == category } + .drop(offset) + .take(limit) + override suspend fun search(query: MemorySearchQuery): List = + notes.values + .filter { query.category == null || it.category == query.category } + .map { MemorySearchResult(it, 1.0f) } + .take(query.topK) + override suspend fun delete(id: String): Boolean { + val ok = notes.remove(id) != null + if (ok) ev.tryEmit(MemoryStoreEvent.Deleted(id)) + return ok + } + override suspend fun markUsed(id: String, at: Instant) { + notes[id]?.let { notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1) } + } + override fun events(): Flow = ev.asSharedFlow() + override fun close() {} + fun allIds(): List = notes.keys.sorted() +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/CuratorTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/CuratorTest.kt new file mode 100644 index 0000000..839b85f --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/CuratorTest.kt @@ -0,0 +1,88 @@ +package pw.binom.agentik.standalone.agent.memory + +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.standalone.agent.TestInMemoryMemoryStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Instant +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemorySource + +class CuratorTest { + + @Test + fun `archives notes older than maxAge with zero useCount`() = runTest { + val store = TestInMemoryMemoryStore() + val now = Instant.parse("2026-09-15T00:00:00Z") + store.upsert(note("old", lastUsedOffsetDays = 100, useCount = 0)) + store.upsert(note("fresh", lastUsedOffsetDays = 1, useCount = 0)) + val curator = Curator(store, maxAge = Duration.parse("90d"), clock = FakeClock(now)) + assertEquals(1, curator.runPass()) + assertEquals(listOf("fresh"), store.allIds()) + } + + @Test + fun `keeps notes that were used recently even if old`() = runTest { + val store = TestInMemoryMemoryStore() + val now = Instant.parse("2026-09-15T00:00:00Z") + store.upsert(note("frequently-used", lastUsedOffsetDays = 1, useCount = 50)) + val curator = Curator(store, maxAge = Duration.parse("90d"), clock = FakeClock(now)) + assertEquals(0, curator.runPass()) + assertEquals(listOf("frequently-used"), store.allIds()) + } + + @Test + fun `archives notes with useCount above default when configured`() = runTest { + val store = TestInMemoryMemoryStore() + val now = Instant.parse("2026-09-15T00:00:00Z") + store.upsert(note("twice-used", lastUsedOffsetDays = 100, useCount = 2)) + val curator = Curator( + store, + maxAge = Duration.parse("90d"), + maxUseCount = 5, + clock = FakeClock(now), + ) + assertEquals(1, curator.runPass()) + assertTrue(store.allIds().isEmpty()) + } + + @Test + fun `start and stop launch and cancel the background loop`() = runTest { + val store = TestInMemoryMemoryStore() + val curator = Curator( + store, + interval = Duration.parse("10ms"), + maxAge = Duration.parse("90d"), + ) + curator.start() + Thread.sleep(50) + curator.stop() + // ничего не падает, корутина отменена + } + + private fun note( + id: String, + lastUsedOffsetDays: Long, + useCount: Int, + ): MemoryNote { + val now = Instant.parse("2026-09-15T00:00:00Z") + return MemoryNote( + id = id, + category = MemoryCategory.WORLD, + content = "fact $id", + createdAt = now - Duration.parse("${lastUsedOffsetDays}d"), + lastUsedAt = now - Duration.parse("${lastUsedOffsetDays}d"), + useCount = useCount, + conversationId = null, + source = MemorySource.AGENT_SAVE, + ) + } + + private class FakeClock(private val now: Instant) : Clock { + override fun now(): Instant = now + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt new file mode 100644 index 0000000..fb89e8a --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt @@ -0,0 +1,186 @@ +package pw.binom.agentik.standalone.agent.memory + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemorySource +import pw.binom.agentik.memory.MemoryStore +import pw.binom.agentik.memory.MemoryStoreEvent +import pw.binom.agentik.memory.ReviewedTurn +import pw.binom.agentik.standalone.agent.FakeLiteLlm +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import pw.binom.agentik.llm.tools.LlmMemoryReviewer +import pw.binom.agentik.llm.tools.ReviewPrompts + +class LlmMemoryReviewerTest { + + /** + * Минимальный in-memory store для тестов — реализует [MemoryStore], + * хранит заметки в MutableList, поддерживает events flow. + */ + private class InMemoryStore : MemoryStore { + private val notes = mutableMapOf() + private val _events = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 16) + + override suspend fun upsert(note: MemoryNote) { + notes[note.id] = note + _events.emit(MemoryStoreEvent.Upserted(note)) + } + + override suspend fun get(id: String): MemoryNote? = notes[id] + override suspend fun list( + category: MemoryCategory?, + conversationId: String?, + limit: Int, + offset: Int, + ): List = notes.values + .filter { category == null || it.category == category } + .filter { conversationId == null || it.conversationId == conversationId } + .sortedByDescending { it.lastUsedAt } + .drop(offset) + .take(limit) + + override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List = emptyList() + + override suspend fun delete(id: String): Boolean = notes.remove(id) != null + + override suspend fun markUsed(id: String, at: Instant) { + notes[id]?.let { + notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1) + } + } + + override fun events(): kotlinx.coroutines.flow.Flow = _events + + override fun close() {} + + // Helper for tests to seed notes + fun seed(note: MemoryNote) { + notes[note.id] = note + } + } + + @Test + fun `review parses save JSON and applies upsert`() = runTest { + val llm = FakeLiteLlm().apply { + reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}""" + } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val decision = reviewer.review( + ReviewedTurn( + userMessage = "Меня Саша зовут", + assistantMessage = "Приятно познакомиться, Саша!", + ) + ) + assertEquals(1, decision.toSave.size) + assertEquals(MemoryCategory.USER, decision.toSave[0].category) + + val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) + assertEquals(1, applied.saved) + + val all = store.list() + assertEquals(1, all.size) + assertEquals("Имя — Саша", all[0].content) + assertEquals(MemorySource.AUTO_REVIEW, all[0].source) + } + + @Test + fun `review applies delete decisions`() = runTest { + val llm = FakeLiteLlm().apply { + reply = """{"save":[],"delete":["mem-stale-1"]}""" + } + val store = InMemoryStore().apply { + seed( + MemoryNote( + id = "mem-stale-1", + category = MemoryCategory.USER, + content = "stale", + createdAt = Instant.parse("2026-01-01T00:00:00Z"), + lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"), + useCount = 0, + source = MemorySource.AUTO_REVIEW, + ) + ) + } + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val decision = reviewer.review(ReviewedTurn("удали это", "ок")) + val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) + assertEquals(0, applied.saved) + assertEquals(1, applied.deleted) + assertEquals(0, store.list().size) + } + + @Test + fun `review returns empty decision when LLM produces garbage`() = runTest { + val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val decision = reviewer.review(ReviewedTurn("hi", "hello")) + assertTrue(decision.toSave.isEmpty()) + assertTrue(decision.toDelete.isEmpty()) + } + + @Test + fun `review handles empty LLM reply`() = runTest { + val llm = FakeLiteLlm().apply { reply = "" } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val decision = reviewer.review(ReviewedTurn("hi", "hello")) + assertTrue(decision.toSave.isEmpty()) + } + + @Test + fun `review creates conversation with review system prompt`() = runTest { + val llm = FakeLiteLlm().apply { + reply = """{"save":[],"delete":[]}""" + } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + reviewer.review(ReviewedTurn("u", "a")) + + assertNotNull(llm.lastConfig) + assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction) + } + + @Test + fun `reviewPreCompaction processes batch of turns`() = runTest { + val llm = FakeLiteLlm().apply { + reply = """ + {"save":[ + {"category":"USER","content":"Работает в Яндексе"}, + {"category":"WORLD","content":"JVector — pure-Java ANN"} + ],"delete":[]} + """.trimIndent() + } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val turns = listOf( + pw.binom.agentik.memory.ConversationTurn( + userMessage = "Я в Яндексе работаю", + assistantMessage = "Круто!", + ), + pw.binom.agentik.memory.ConversationTurn( + userMessage = "А что за JVector?", + assistantMessage = "ANN-библиотека на Java.", + ), + ) + + val decision = reviewer.reviewPreCompaction(turns) + val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) + + assertEquals(2, applied.saved) + assertEquals(2, store.list().size) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt new file mode 100644 index 0000000..e45c32a --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt @@ -0,0 +1,107 @@ +package pw.binom.agentik.standalone.agent.memory + +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryReviewDecision +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import pw.binom.agentik.llm.tools.ReviewDecisionParser + +class ReviewDecisionParserTest { + + @Test + fun `parses save array with USER category`() { + val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}""" + val decision = ReviewDecisionParser.parse(raw) + assertEquals(1, decision.toSave.size) + assertEquals(MemoryCategory.USER, decision.toSave[0].category) + assertEquals("Имя пользователя — Саша", decision.toSave[0].content) + assertTrue(decision.toDelete.isEmpty()) + } + + @Test + fun `parses all three categories`() { + val raw = """ + {"save":[ + {"category":"USER","content":"Работает в Яндексе"}, + {"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"}, + {"category":"PREFERENCE","content":"Отвечать кратко"} + ],"delete":[]} + """.trimIndent() + val decision = ReviewDecisionParser.parse(raw) + assertEquals(3, decision.toSave.size) + assertEquals(MemoryCategory.USER, decision.toSave[0].category) + assertEquals(MemoryCategory.WORLD, decision.toSave[1].category) + assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category) + } + + @Test + fun `parses delete array with ids`() { + val raw = """{"save":[],"delete":["mem-123","mem-456"]}""" + val decision = ReviewDecisionParser.parse(raw) + assertTrue(decision.toSave.isEmpty()) + assertEquals(listOf("mem-123", "mem-456"), decision.toDelete) + } + + @Test + fun `returns empty decision on empty input`() { + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse("")) + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" ")) + } + + @Test + fun `returns empty decision on non-JSON garbage`() { + val raw = "Извини, я не могу помочь с этим." + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) + } + + @Test + fun `returns empty decision on malformed JSON`() { + val raw = """{"save":[{"category":"USER","content":"foo""" // truncated + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) + } + + @Test + fun `extracts JSON from markdown code block`() { + val raw = """ + Вот JSON: + ```json + {"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]} + ``` + """.trimIndent() + val decision = ReviewDecisionParser.parse(raw) + assertEquals(1, decision.toSave.size) + assertEquals(MemoryCategory.WORLD, decision.toSave[0].category) + assertEquals("SQLite 3.51", decision.toSave[0].content) + } + + @Test + fun `skips entries with unknown category`() { + val raw = """{"save":[ + {"category":"USER","content":"valid"}, + {"category":"NOT_A_CATEGORY","content":"should be skipped"}, + {"category":"WORLD","content":"valid too"} + ],"delete":[]}""" + val decision = ReviewDecisionParser.parse(raw) + assertEquals(2, decision.toSave.size) + assertEquals("valid", decision.toSave[0].content) + assertEquals("valid too", decision.toSave[1].content) + } + + @Test + fun `skips entries with blank content`() { + val raw = """{"save":[ + {"category":"USER","content":""}, + {"category":"WORLD","content":" "} + ],"delete":[]}""" + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) + } + + @Test + fun `handles escaped quotes in content`() { + val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}""" + val decision = ReviewDecisionParser.parse(raw) + assertEquals(1, decision.toSave.size) + assertEquals("Сказал \"привет\"", decision.toSave[0].content) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt new file mode 100644 index 0000000..9aa9742 --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt @@ -0,0 +1,199 @@ +package pw.binom.agentik.standalone.config + +import kotlinx.serialization.json.Json +import pw.binom.agentik.standalone.llm.LlmBackend +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AppConfigTest { + + private fun openAiEnv( + extra: Map = emptyMap(), + ): (String) -> String? = { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + else -> extra[name] + } + } + + @Test + fun `defaults applied when port and db path absent`() { + val cfg = AppConfig.fromEnv(openAiEnv()) + assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port) + assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath) + } + + @Test + fun `reads port and db path from env`() { + val cfg = AppConfig.fromEnv( + openAiEnv(mapOf("AGENTIK_PORT" to "9999", "AGENTIK_DB_PATH" to "/tmp/x.db")), + ) + assertEquals(9999, cfg.agent.port) + assertEquals("/tmp/x.db", cfg.agent.dbPath) + } + + @Test + fun `blank db path falls back to default`() { + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_DB_PATH" to " "))) + assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath) + } + + @Test + fun `non-numeric port falls back to default`() { + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_PORT" to "not-a-port"))) + assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port) + } + + @Test + fun `delegates llm to AppConfig fromEnv`() { + val cfg = AppConfig.fromEnv(openAiEnv()) + assertEquals(LlmBackend.OPENAI, cfg.llm.backend) + assertEquals("gpt-4o-mini", cfg.llm.openai?.model) + } + + @Test + fun `google backend is selected via env`() { + val cfg = AppConfig.fromEnv { name -> + when (name) { + "AGENTIK_LLM_BACKEND" -> "google" + "AGENTIK_GOOGLE_MODEL_PATH" -> "/models/gemma.litertlm" + else -> null + } + } + assertEquals(LlmBackend.GOOGLE, cfg.llm.backend) + assertEquals("/models/gemma.litertlm", cfg.llm.google?.modelPath) + } + + @Test + fun `missing required llm env propagates`() { + assertFailsWith { + AppConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null } + } + } + + @Test + fun `mcp empty when no config path`() { + val cfg = AppConfig.fromEnv(openAiEnv()) + assertTrue(cfg.mcp.isEmpty) + } + + @Test + fun `mcp loaded from file`() { + val tmp = kotlin.io.path.createTempFile(suffix = ".json").toFile() + try { + tmp.writeText( + """ + { + "mcpServers": { + "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] } + } + } + """.trimIndent(), + ) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_MCP_CONFIG" to tmp.absolutePath))) + assertEquals(1, cfg.mcp.servers.size) + assertEquals("fetch", cfg.mcp.servers.first().name) + } finally { + tmp.delete() + } + } + + @Test + fun `skills dir defaults to null`() { + assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.skillsDir) + } + + @Test + fun `skills dir read from env`() { + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills"))) + assertEquals("/skills", cfg.agent.skillsDir) + } + + @Test + fun `blank skills dir falls back to null`() { + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " "))) + assertEquals(null, cfg.agent.skillsDir) + } + + @Test + fun `soul path defaults to null`() { + assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.soulPath) + } + + @Test + fun `soul path read from env`() { + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md"))) + assertEquals("/etc/SOUL.md", cfg.agent.soulPath) + } + + @Test + fun `blank soul path falls back to null`() { + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " "))) + assertEquals(null, cfg.agent.soulPath) + } + + @Test + fun `serialization round-trips through json`() { + val original = AppConfig.fromEnv( + openAiEnv( + mapOf( + "AGENTIK_PORT" to "7777", + "AGENTIK_DB_PATH" to "/tmp/x.db", + "AGENTIK_SYSTEM_PROMPT" to "be brief", + "AGENTIK_SKILLS_DIR" to "/skills", + ), + ), + ).copy( + mcp = pw.binom.agentik.mcp.bridge.McpConfig( + servers = listOf( + pw.binom.agentik.mcp.bridge.McpServerSpec.Stdio( + name = "fetch", + command = "uvx", + args = listOf("mcp-server-fetch"), + ), + pw.binom.agentik.mcp.bridge.McpServerSpec.Http( + name = "remote", + url = "https://example.com/mcp", + headers = mapOf("Authorization" to "Bearer x"), + ), + ), + ), + ) + + val json = Json { encodeDefaults = true } + val text = json.encodeToString(AppConfig.serializer(), original) + val restored = json.decodeFromString(AppConfig.serializer(), text) + + assertEquals(original, restored) + } + + @Test + fun `compressionThreshold defaults to 0_8 when env unset`() { + val cfg = AppConfig.fromEnv(openAiEnv()) + assertEquals(0.8, cfg.memory.compressionThreshold) + } + + @Test + fun `compressionThreshold parsed from env`() { + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6"))) + assertEquals(0.6, cfg.memory.compressionThreshold) + } + + @Test + fun `compressionThreshold clamped between min and max`() { + val tooLow = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01"))) + assertEquals(0.1, tooLow.memory.compressionThreshold) + val tooHigh = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5"))) + assertEquals(0.99, tooHigh.memory.compressionThreshold) + } + + @Test + fun `compressionThreshold garbage falls back to default`() { + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен"))) + assertEquals(0.8, cfg.memory.compressionThreshold) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt new file mode 100644 index 0000000..99e491a --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt @@ -0,0 +1,145 @@ +package pw.binom.agentik.standalone.llm + +import pw.binom.agentik.standalone.config.AppConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LlmConfigTest { + + private fun openAiEnv(extra: Map = emptyMap()): (String) -> String? = { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + else -> extra[name] + } + } + + @Test + fun `fromEnv - happy path`() { + val cfg = AppConfig.fromEnv { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + "AGENTIK_SYSTEM_PROMPT" -> "be brief" + else -> null + } + }.llm + assertEquals("be brief", cfg.systemPrompt) + assertEquals(OpenAiConfig(baseUrl = "https://api.openai.com/v1", apiKey = "sk-test", model = "gpt-4o-mini"), cfg.openai) + } + + @Test + fun `fromEnv - falls back to default system prompt`() { + val cfg = AppConfig.fromEnv(openAiEnv()).llm + assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt) + } + + @Test + fun `fromEnv - missing base url throws`() { + assertFailsWith { + AppConfig.fromEnv { name -> + when (name) { + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + else -> null + } + } + } + } + + @Test + fun `fromEnv - missing api key throws`() { + assertFailsWith { + AppConfig.fromEnv { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_MODEL" -> "gpt-4o-mini" + else -> null + } + } + } + } + + @Test + fun `fromEnv - missing model throws`() { + assertFailsWith { + AppConfig.fromEnv { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + else -> null + } + } + } + } + + @Test + fun `blank system prompt from env falls back to default`() { + val cfg = AppConfig.fromEnv { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + "AGENTIK_SYSTEM_PROMPT" -> " " + else -> null + } + }.llm + assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt) + } + + @Test + fun `fromEnv - OPENAI_CONTEXT_WINDOW parsed into OpenAiConfig`() { + val cfg = AppConfig.fromEnv { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + "OPENAI_CONTEXT_WINDOW" -> "128000" + else -> null + } + }.llm + assertEquals(128_000, cfg.openai?.contextWindow) + } + + @Test + fun `contextWindow - env parsed once and exposed via derived prop`() { + // Старый resolveContextWindow проверял "env wins over config" — но в новой + // модели env и config это одно и то же значение (env читается один раз в + // AppConfig.fromEnv и сохраняется в OpenAiConfig.contextWindow). Поэтому + // тут проверяем, что derived-prop LlmConfig.contextWindow правильно + // прокидывает значение из OpenAiConfig для активного бэкенда. + val cfg = AppConfig.fromEnv { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + "OPENAI_CONTEXT_WINDOW" -> "64000" + else -> null + } + }.llm + assertEquals(64_000, cfg.contextWindow) + } + + @Test + fun `contextWindow - returns null when nothing set`() { + val cfg = AppConfig.fromEnv(openAiEnv()).llm + assertEquals(null, cfg.contextWindow) + } + + @Test + fun `fromEnv - OPENAI_CONTEXT_WINDOW garbage falls back to null`() { + val cfg = AppConfig.fromEnv { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + "OPENAI_CONTEXT_WINDOW" -> "не-число" + else -> null + } + }.llm + assertEquals(null, cfg.openai?.contextWindow) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/llm/ModelDownloaderTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/llm/ModelDownloaderTest.kt new file mode 100644 index 0000000..db3259f --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/llm/ModelDownloaderTest.kt @@ -0,0 +1,174 @@ +package pw.binom.agentik.standalone.llm + +import io.ktor.client.HttpClient +import io.ktor.server.application.call +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.response.respondBytes +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.head +import io.ktor.server.routing.routing +import io.ktor.http.HttpStatusCode +import io.ktor.http.HttpHeaders as KH +import io.ktor.utils.io.toByteArray +import java.io.File +import java.net.ServerSocket +import java.nio.file.Path +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlinx.coroutines.runBlocking + +class ModelDownloaderTest { + + private val payload = ByteArray(8192) { (it and 0xff).toByte() } + + /** Поднимает fake-HTTP-server с поддержкой HEAD/GET/Range и возвращает `port`. */ + private fun startFakeServer(): FakeServer { + val port = ServerSocket(0).use { it.localPort } + val server = embeddedServer(CIO, port = port) { + routing { + head("/model.litertlm") { + call.response.headers.append(KH.AcceptRanges, "bytes") + call.response.headers.append(KH.ContentLength, payload.size.toString()) + call.respondText("") + } + get("/model.litertlm") { + val range = call.request.headers[KH.Range] + if (range == null) { + call.response.headers.append(KH.ContentLength, payload.size.toString()) + call.respondBytes(payload) + } else { + // Parse "bytes=N-" + val n = range.substringAfter("bytes=").substringBefore('-').toLong() + val slice = payload.copyOfRange(n.toInt(), payload.size) + call.response.status(HttpStatusCode.PartialContent) + call.response.headers.append(KH.ContentRange, "bytes $n-${payload.size - 1}/${payload.size}") + call.response.headers.append(KH.ContentLength, slice.size.toString()) + call.respondBytes(slice) + } + } + } + } + server.start(wait = false) + return FakeServer(server, port) + } + + private fun tmpFile(): File { + val dir: Path = createTempDirectory(prefix = "agentik-test-") + return dir.resolve("model.litertlm").toFile() + } + + @Test + fun `download writes full file when no part exists`() = runBlocking { + val fake = startFakeServer() + try { + val dest = tmpFile() + val dl = ModelDownloader() + val result = dl.download( + url = "http://127.0.0.1:${fake.port}/model.litertlm", + destPath = dest.absolutePath, + ) + assertEquals(payload.size.toLong(), result.bytes) + assertEquals(0L, result.resumedFrom) + assertContentEquals(payload, dest.readBytes()) + assertTrue(!File("${dest.absolutePath}.part").exists(), "part file should be cleaned up") + } finally { + fake.server.stop(100, 200) + } + } + + @Test + fun `download is no-op when final file already complete`() = runBlocking { + val fake = startFakeServer() + try { + val dest = tmpFile() + dest.writeBytes(payload) + val dl = ModelDownloader() + val result = dl.download( + url = "http://127.0.0.1:${fake.port}/model.litertlm", + destPath = dest.absolutePath, + ) + assertEquals(0L, result.bytes) + assertEquals(payload.size.toLong(), result.total) + assertContentEquals(payload, dest.readBytes()) + } finally { + fake.server.stop(100, 200) + } + } + + @Test + fun `download resumes from existing part file with Range request`() = runBlocking { + val fake = startFakeServer() + try { + val dest = tmpFile() + val partFile = File("${dest.absolutePath}.part") + val prefixSize = 4096 + partFile.writeBytes(payload.copyOfRange(0, prefixSize)) + + val dl = ModelDownloader() + val result = dl.download( + url = "http://127.0.0.1:${fake.port}/model.litertlm", + destPath = dest.absolutePath, + ) + assertEquals(prefixSize.toLong(), result.resumedFrom) + assertEquals(payload.size.toLong(), result.bytes) + assertContentEquals(payload, dest.readBytes()) + } finally { + fake.server.stop(100, 200) + } + } + + @Test + fun `download reports progress via callback`() = runBlocking { + val fake = startFakeServer() + try { + val dest = tmpFile() + val dl = ModelDownloader() + val reports = mutableListOf>() + dl.download( + url = "http://127.0.0.1:${fake.port}/model.litertlm", + destPath = dest.absolutePath, + progress = { d, t -> reports += d to t }, + ) + assertTrue(reports.isNotEmpty(), "progress must be reported at least once") + assertEquals(payload.size.toLong(), reports.last().first) + assertEquals(payload.size.toLong(), reports.last().second) + } finally { + fake.server.stop(100, 200) + } + } + + @Test + fun `download fails with clear message on HTTP 404`() = runBlocking { + // Spin up server that always 404s + val port = ServerSocket(0).use { it.localPort } + val server = embeddedServer(CIO, port = port) { + routing { + head("/missing") { call.respondText("", status = HttpStatusCode.NotFound) } + get("/missing") { call.respondText("", status = HttpStatusCode.NotFound) } + } + } + server.start(wait = false) + try { + val dest = tmpFile() + val dl = ModelDownloader() + try { + dl.download(url = "http://127.0.0.1:$port/missing", destPath = dest.absolutePath) + fail("expected failure on 404") + } catch (e: Exception) { + val msg = e.message ?: "" + assertTrue("404" in msg || "Not Found" in msg, + "error should mention HTTP 404, got: $msg") + } + } finally { + server.stop(100, 200) + } + } + + private data class FakeServer(val server: io.ktor.server.engine.EmbeddedServer<*, *>, val port: Int) +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt new file mode 100644 index 0000000..b49eeff --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt @@ -0,0 +1,90 @@ +package pw.binom.agentik.standalone.mcp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import pw.binom.agentik.mcp.bridge.McpConfig +import pw.binom.agentik.mcp.bridge.McpServerSpec + +class McpConfigTest { + + @Test + fun `fromEnv returns empty when env var unset`() { + val cfg = McpConfig.fromEnv { null } + assertTrue(cfg.isEmpty) + assertEquals(emptyList(), cfg.servers) + } + + @Test + fun `fromEnv returns empty when env var blank`() { + val cfg = McpConfig.fromEnv { "" } + assertTrue(cfg.isEmpty) + } + + @Test + fun `fromEnv returns empty when file missing`() { + val cfg = McpConfig.fromEnv { "/tmp/agentik-nonexistent-mcp-${System.nanoTime()}.json" } + assertTrue(cfg.isEmpty) + } + + @Test + fun `fromJson parses stdio server`() { + val json = """ + { "mcpServers": { + "fs": { "command": "npx", "args": ["-y", "fs-mcp"], "env": { "ROOT": "/work" } } + } } + """.trimIndent() + val cfg = McpConfig.fromJson(json) + assertEquals(1, cfg.servers.size) + val s = cfg.servers.single() as McpServerSpec.Stdio + assertEquals("fs", s.name) + assertEquals("npx", s.command) + assertEquals(listOf("-y", "fs-mcp"), s.args) + assertEquals(mapOf("ROOT" to "/work"), s.env) + } + + @Test + fun `fromJson parses http server with headers`() { + val json = """ + { "mcpServers": { + "remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer X" } } + } } + """.trimIndent() + val cfg = McpConfig.fromJson(json) + val s = cfg.servers.single() as McpServerSpec.Http + assertEquals("remote", s.name) + assertEquals("https://example.com/mcp", s.url) + assertEquals(mapOf("Authorization" to "Bearer X"), s.headers) + } + + @Test + fun `fromJson parses both stdio and http together`() { + val json = """ + { "mcpServers": { + "fs": { "command": "npx", "args": [] }, + "remote":{ "url": "https://example.com/mcp" } + } } + """.trimIndent() + val cfg = McpConfig.fromJson(json) + assertEquals(2, cfg.servers.size) + assertTrue(cfg.servers.any { it is McpServerSpec.Stdio && it.name == "fs" }) + assertTrue(cfg.servers.any { it is McpServerSpec.Http && it.name == "remote" }) + } + + @Test + fun `fromJson skips entries without command or url`() { + val json = """ + { "mcpServers": { + "broken": { "description": "no transport" } + } } + """.trimIndent() + val cfg = McpConfig.fromJson(json) + assertTrue(cfg.isEmpty) + } + + @Test + fun `empty returns empty config`() { + val cfg = McpConfig.empty() + assertTrue(cfg.isEmpty) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt new file mode 100644 index 0000000..8eadffd --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt @@ -0,0 +1,50 @@ +package pw.binom.agentik.standalone.mcp + +import pw.binom.litert.LiteTool +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import pw.binom.agentik.toolsets.NamedTool +import pw.binom.agentik.mcp.bridge.McpConfig +import pw.binom.agentik.mcp.bridge.McpRegistry + +class McpRegistryTest { + + @Test + fun `empty config produces empty registry`() { + val registry = McpRegistry.fromConfig(McpConfig.empty()) + assertEquals(0, registry.allTools.size) + assertEquals(0, registry.connectedServerCount) + assertEquals(emptyList(), registry.namedTools) + registry.close() + } + + @Test + fun `empty servers list produces empty registry`() { + val registry = McpRegistry(servers = emptyList()) + assertEquals(0, registry.namedTools.size) + assertEquals(0, registry.allTools.size) + registry.close() + } + + @Test + fun `NamedTool holds name and tool reference`() { + val noop: LiteTool = object : LiteTool { + override fun describe(): String = "{}" + override fun invoke(arguments: String): String = "" + } + val nt = NamedTool(name = "server__echo", tool = noop) + assertEquals("server__echo", nt.name) + assertSame(noop, nt.tool) + assertEquals("{}", nt.tool.describe()) + } + + @Test + fun `registry close is idempotent`() { + val registry = McpRegistry.fromConfig(McpConfig.empty()) + registry.close() + registry.close() // should not throw + assertTrue(true) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt new file mode 100644 index 0000000..08270aa --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt @@ -0,0 +1,372 @@ +package pw.binom.agentik.standalone.persistence +import pw.binom.agentik.journal.MessageContext +import pw.binom.agentik.journal.MessageOrigin +import pw.binom.agentik.journal.ConversationRecord +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.journal.Content +import pw.binom.agentik.context.WorkingMemoryEntry + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.storage.ksqlite.KsqliteStores +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class PersistenceTest { + + private lateinit var stores: KsqliteStores + + @BeforeTest + fun setup() { + stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}") + } + + @AfterTest + fun tearDown() { + stores.close() + } + + @Test + fun `upsert + get conversation - roundtrip`() = runTest { + val now = Instant.fromEpochMilliseconds(1_700_000_000_000) + val rec = ConversationRecord( + id = "c1", + title = "Hello", + isTemporal = false, + createdAt = now, + updatedAt = now, + ) + stores.conversations.upsert(rec) + val got = stores.conversations.get("c1") + assertNotNull(got) + assertEquals(rec.id, got.id) + assertEquals(rec.title, got.title) + assertEquals(rec.isTemporal, got.isTemporal) + assertEquals(rec.createdAt, got.createdAt) + assertEquals(rec.updatedAt, got.updatedAt) + } + + @Test + fun `upsert overwrites existing record`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.conversations.upsert( + ConversationRecord("c1", title = "A", isTemporal = false, createdAt = t0, updatedAt = t0), + ) + val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000) + stores.conversations.upsert( + ConversationRecord("c1", title = "B", isTemporal = true, createdAt = t0, updatedAt = t1), + ) + val got = stores.conversations.get("c1")!! + assertEquals("B", got.title) + assertTrue(got.isTemporal) + assertEquals(t1, got.updatedAt) + } + + @Test + fun `list returns conversations ordered by updated_at desc`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + repeat(3) { i -> + stores.conversations.upsert( + ConversationRecord( + id = "c$i", + title = null, + isTemporal = false, + createdAt = t0, + updatedAt = Instant.fromEpochMilliseconds(1_700_000_000_000 + i * 1000), + ), + ) + } + val list = stores.conversations.list(offset = 0, limit = 10) + assertEquals(listOf("c2", "c1", "c0"), list.map { it.id }) + } + + @Test + fun `delete cascades messages and working_memory`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.conversations.upsert( + ConversationRecord("c1", null, false, t0, t0), + ) + stores.messages.append( + MessageRecord.UserMessage( + id = "m1", + conversationId = "c1", + content = listOf(Content.Text("hello")), + createdAt = t0, + ), + ) + stores.workingMemory.append( + conversationId = "c1", + entry = WorkingMemoryEntry.User( + sourceMessageId = "m1", + content = listOf(Content.Text("hello")), + ), + now = t0, + ) + assertEquals(1, stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList().size) + assertEquals(1, stores.workingMemory.list("c1").size) + + val removed = stores.conversations.delete("c1") + assertTrue(removed) + assertNull(stores.conversations.get("c1")) + assertEquals(emptyList(), stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()) + assertEquals(emptyList(), stores.workingMemory.list("c1")) + } + + @Test + fun `message audit log - append and read back`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.messages.append( + MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0), + ) + stores.messages.append( + MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0), + ) + + val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() + assertEquals(2, all.size) + assertEquals("m1", all[0].id) + assertEquals("m2", all[1].id) + assertTrue(all[0] is MessageRecord.UserMessage) + assertTrue(all[1] is MessageRecord.AssistantMessage) + assertEquals("hi", (all[0] as MessageRecord.UserMessage).content[0].let { + (it as Content.Text).body + }) + } + + @Test + fun `message after timestamp filter`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000) + stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0)) + stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1)) + + val after = stores.messages.list("c1", after = t0, offset = 0, limit = 10) + assertEquals(1, after.size) + assertEquals("m2", after[0].id) + } + + @Test + fun `working memory - append + list preserves order`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000) + stores.workingMemory.append( + conversationId = "c1", + entry = WorkingMemoryEntry.User(sourceMessageId = "m1", content = listOf(Content.Text("hi"))), + now = t0, + ) + stores.workingMemory.append( + conversationId = "c1", + entry = WorkingMemoryEntry.Assistant(sourceMessageId = "m2", content = listOf(Content.Text("yo"))), + now = t1, + ) + val list = stores.workingMemory.list("c1") + assertEquals(2, list.size) + assertTrue(list[0].entry is WorkingMemoryEntry.User) + assertTrue(list[1].entry is WorkingMemoryEntry.Assistant) + } + + @Test + fun `working memory - compact without summary just drops tail`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0) + stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0) + stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0) + val rows = stores.workingMemory.list("c1") + // Drop начиная со второго хода (User m1) — должно остаться только User m1. + val dropFrom = rows[1].orderIdx + stores.workingMemory.compact(dropFrom, "c1", summaryText = null) + val after = stores.workingMemory.list("c1") + assertEquals(1, after.size) + assertTrue(after[0].entry is WorkingMemoryEntry.User) + } + + @Test + fun `working memory - compact with summary inserts Summary entry`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0) + stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0) + stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0) + val rows = stores.workingMemory.list("c1") + val dropFrom = rows[1].orderIdx + stores.workingMemory.compact(dropFrom, "c1", summaryText = "**Goal**: chat\n**Active**: at u2\n**Resolved**: a1") + val after = stores.workingMemory.list("c1") + assertEquals(2, after.size) + assertTrue(after[0].entry is WorkingMemoryEntry.User) + val summary = after[1].entry + assertIs(summary) + assertTrue(summary.text.startsWith("**Goal**")) + // order_idx должен быть > всех оставшихся + assertTrue(after[1].orderIdx > after[0].orderIdx) + // sourceMessageId у Summary всегда null + assertNull(after[1].sourceMessageId) + } + + @Test + fun `working memory - compact with blank summaryText behaves as drop`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0) + val rows = stores.workingMemory.list("c1") + stores.workingMemory.compact(rows[0].orderIdx + 1, "c1", summaryText = "") + val after = stores.workingMemory.list("c1") + assertEquals(1, after.size) + assertTrue(after[0].entry is WorkingMemoryEntry.User) + } + + @Test + fun `working memory - compact is atomic on other conversations`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0) + stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0) + stores.workingMemory.append("c2", WorkingMemoryEntry.User("m2", listOf(Content.Text("u2"))), t0) + stores.workingMemory.append("c2", WorkingMemoryEntry.Assistant("m3", listOf(Content.Text("a2"))), t0) + stores.workingMemory.compact(2, "c1", summaryText = "sum") + val c1 = stores.workingMemory.list("c1") + val c2 = stores.workingMemory.list("c2") + // c1: User m1 + Summary + assertEquals(2, c1.size) + assertTrue(c1[1].entry is WorkingMemoryEntry.Summary) + // c2 не тронут + assertEquals(2, c2.size) + assertTrue(c2[1].entry is WorkingMemoryEntry.Assistant) + } + + @Test + fun `rename updates title and bumps updated_at`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.conversations.upsert(ConversationRecord("c1", null, false, t0, t0)) + val newTs = stores.conversations.rename("c1", "Renamed") + assertNotNull(newTs) + assertTrue(newTs > t0) + val got = stores.conversations.get("c1")!! + assertEquals("Renamed", got.title) + assertEquals(newTs, got.updatedAt) + } + + @Test + fun `rename can clear title`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.conversations.upsert(ConversationRecord("c1", "Title", false, t0, t0)) + stores.conversations.rename("c1", null) + val got = stores.conversations.get("c1")!! + assertNull(got.title) + } + + @Test + fun `delete returns false when conversation does not exist`() = runTest { + assertFalse(stores.conversations.delete("nope")) + } + + @Test + fun `error record roundtrip through audit log`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.messages.append( + MessageRecord.Error( + id = "e1", + conversationId = "c1", + message = "boom", + code = "E42", + createdAt = t0, + ), + ) + stores.messages.append( + MessageRecord.Error( + id = "e2", + conversationId = "c1", + message = "no code", + code = null, + createdAt = Instant.fromEpochMilliseconds(1_700_000_001_000), + ), + ) + val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() + assertEquals(2, all.size) + val first = assertIs(all[0]) + assertEquals("e1", first.id) + assertEquals("boom", first.message) + assertEquals("E42", first.code) + assertEquals(t0, first.createdAt) + assertNull(assertIs(all[1]).code) + } + + @Test + fun `image content roundtrip through message payload`() = runTest { val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + val bytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header + stores.messages.append( + MessageRecord.UserMessage( + id = "m1", + conversationId = "c1", + content = listOf(Content.Image(data = bytes, mime = "image/png")), + createdAt = t0, + ), + ) + val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() + val image = (all[0] as MessageRecord.UserMessage).content[0] as Content.Image + assertEquals("image/png", image.mime) + assertTrue(bytes.contentEquals(image.data)) + } + + @Test + fun `user message context roundtrips through SQLite`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + val ctx = MessageContext( + origin = MessageOrigin.EVENT, + description = "scheduled cron morning-briefing", + sourceId = "cron-42", + ) + stores.messages.append( + MessageRecord.UserMessage( + id = "m1", + conversationId = "c1", + content = listOf(Content.Text("wake up")), + createdAt = t0, + context = ctx, + ), + ) + val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() + assertEquals(1, all.size) + val user = assertIs(all[0]) + assertEquals(ctx, user.context) + } + + @Test + fun `user message without context roundtrips with null context`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + stores.messages.append( + MessageRecord.UserMessage( + id = "m1", + conversationId = "c1", + content = listOf(Content.Text("regular user message")), + createdAt = t0, + ), + ) + val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() + val user = assertIs(all[0]) + assertNull(user.context) + } + + @Test + fun `working memory user entry context roundtrips through SQLite`() = runTest { + val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) + val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "agent startup") + stores.workingMemory.append( + conversationId = "c1", + entry = WorkingMemoryEntry.User( + sourceMessageId = "m1", + content = listOf(Content.Text("boot")), + context = ctx, + ), + now = t0, + ) + val list = stores.workingMemory.list("c1") + assertEquals(1, list.size) + val user = assertIs(list[0].entry) + assertEquals(ctx, user.context) + } +}