feat(memory): add :memory-md-vector hybrid backend + :reflection-api + outbox/journal entity splits
ci / JVM build + tests (push) Failing after 2m59s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 9s

- :memory-md-vector (KMP jvm+linuxX64+mingwX64): .md-файлы как source of
  truth, векторный индекс (sqlite-vec) как derived cache. reconcile()
  на старте: orphan-cleanup + content-hash-gated re-embed. Гибридный
  скор 0.7*vector + 0.3*keyword. Заменяет EmbeddingProvider на
  KMP-TextEmbeddingExecutor из :memory-api.

- :reflection-api: новый 4-й API-модуль (Reflection, ReflectionStore,
  ReflectionEvent). Зависит только от :memory-api.

- :journal-api получил ConversationRecord/ConversationStore/Ids (бывший
  :message-store-api, полностью удалён). :outbox-api получил Event,
  CommonEvent, AgentEvent (бывший :event-store).

- :memory-api получил MemoryVectorIndex + NoteMatches +
  TextEmbeddingExecutor (suspend-обёртка над TextEmbeddingExtractor).

- :memory-vector KMP-цели достигнуты через commonMain-only TextEmbedding-
  Executor, EmbeddingProvider выпилен; :memory-md-vector тянет
  text-embedding-api транзитивно через :memory-api.

- :standalone flatten в commonMain/commonTest завершён (тесты из jvmTest
  переехали в commonTest). Включён optional деп :memory-md-vector через
  AGENTIK_MEMORY_BACKEND=md-vector.

jvmTest: 96 задач, 407 тестов, 0 падений.
This commit is contained in:
subochev
2026-09-21 23:10:28 +03:00
parent 161be41adf
commit 0a7c40688c
41 changed files with 4952 additions and 0 deletions
@@ -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,
)
@@ -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<ConversationRecord>
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
suspend fun rename(id: String, title: String?): Instant?
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */
suspend fun touch(id: String, now: Instant)
}
@@ -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-сборке.
*
* Сохраняет формат `<prefix>-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` —
* `:server` его парсит как opaque string, без знания внутренней структуры.
*/
object Ids {
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
}
@@ -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<ScoredVector>
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
suspend fun flush()
override fun close()
}
@@ -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
}
@@ -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<String>): List<FloatArray> =
texts.map { embed(it) }
/** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */
override fun close() {
extractor.close()
}
}
+51
View File
@@ -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)
}
}
}
@@ -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<MemoryNote> = mdStore.list(limit = Int.MAX_VALUE)
val onDiskById: Map<String, MemoryNote> = onDisk.associateBy { it.id }
val inCache: List<KsqliteVectorIndex.MetaEntry> = vectorIndex.allMeta()
val cachedIds: Set<String> = 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<MemorySearchResult> {
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<Pair<MemoryNote, Float>> = 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<MemoryNote> = 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<MemoryStoreEvent> = 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
}
}
@@ -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)
}
@@ -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(),
)
}
@@ -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<Int, SQLitePreparedStatement>()
/**
* Выдать 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<MetaEntry> = mutex.withLock {
allMetaStmt.reset()
allMetaStmt.clearBindings()
val out = mutableListOf<MetaEntry>()
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<ScoredVector> {
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<Pair<Long, Float>>()
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<ScoredVector>()
// 2-й запрос: meta по списку rowid.
val rowIds = candidates.map { it.first }
val idByRowId = HashMap<Long, String>(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
}
}
@@ -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")
}
}
@@ -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()
}
}
@@ -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)
@@ -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
}
@@ -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
}
@@ -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
}
+34
View File
@@ -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)
}
}
}
@@ -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<String>,
)
/**
* Хранилище рефлексий. 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<Reflection>
/** Рефлексии для конкретного диалога. */
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
suspend fun deleteOlderThan(cutoff: Instant)
suspend fun count(): Int
/** Стрим новых рефлексий для подписчиков (для UI в будущем). */
fun events(): Flow<ReflectionEvent> = 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()}"
}
@@ -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")
}
}
@@ -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<NamedTool> = 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<ProtoEvent>()
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<pw.binom.agentik.journal.MessageRecord.UserMessage>(msgs[0])
val err = assertIs<pw.binom.agentik.journal.MessageRecord.Error>(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<ProtoEvent>()
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<pw.binom.agentik.journal.MessageRecord.UserMessage>(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<ProtoEvent>()
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<pw.binom.agentik.journal.MessageRecord.ToolResult>().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<LiteDelta> = 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<AgentEvent>()
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
// Agent.events() удалён из :proto — события живут в
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
// распаковываем .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<LiteMessage>()
override val history: List<LiteMessage> get() = hist.toList()
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
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<LiteContentPart>): 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<LiteDelta> = error("not used")
override fun close() {}
}
@@ -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<pw.binom.litert.LiteDelta> =
throw UnsupportedOperationException("not used in this test")
override val capabilities: pw.binom.litert.LiteCapabilities? = null
override fun close() {}
}
private fun newAgent(
toolsets: List<ToolsetContribution> = emptyList(),
): Pair<ChatAgent, pw.binom.agentik.storage.ksqlite.KsqliteStores> {
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]"
}
}
@@ -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<SummaryTurn>): String {
calls++
return result
}
}
/**
* (InMemoryMemoryStore вынесен в [TestInMemoryMemoryStore].)
*/
@@ -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<LiteContentPart.Text>(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<LiteContentPart.Text>(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<LiteContentPart.Text>(out[0])
val second = assertIs<LiteContentPart.Text>(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>(LiteContentPart.Text("just prefix"))
val out = applyContextPrefix(parts, ctx)
assertEquals(1, out.size)
val text = assertIs<LiteContentPart.Text>(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?)
}
}
@@ -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<Pair<String, Map<String, Any?>>>) : 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<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
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<LiteDelta> =
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<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
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<LiteContentPart>): 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() {}
}
@@ -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<pw.binom.litert.LiteContentPart.Text>()
?.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<pw.binom.litert.LiteContentPart.Text>()
?.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<Unit>()
val reviewer = object : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
error("review() не должен вызываться — только reviewPreCompaction() на compaction")
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): 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<MemorySearchResult>,
) : MemoryPrefetcher {
override suspend fun prefetch(query: String, topK: Int, category: MemoryCategory?): List<MemoryNote> {
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<SummaryTurn>): String =
if (turns.isEmpty()) "" else "compacted-${turns.size}-turns"
}
@@ -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<String>(), 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)
}
}
@@ -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<ConversationTurn> = (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")
}
}
@@ -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)
}
}
@@ -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(""))
}
}
@@ -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
}
}
@@ -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<String, MemoryNote>()
private val ev = MutableSharedFlow<MemoryStoreEvent>(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<MemoryNote> =
notes.values
.filter { category == null || it.category == category }
.drop(offset)
.take(limit)
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> =
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<MemoryStoreEvent> = ev.asSharedFlow()
override fun close() {}
fun allIds(): List<String> = notes.keys.sorted()
}
@@ -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
}
}
@@ -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<String, MemoryNote>()
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<MemoryStoreEvent>(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<MemoryNote> = 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<pw.binom.agentik.memory.MemorySearchResult> = 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<MemoryStoreEvent> = _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)
}
}
@@ -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)
}
}
@@ -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<String, String> = 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<IllegalStateException> {
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)
}
}
@@ -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<String, String> = 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<IllegalStateException> {
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<IllegalStateException> {
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<IllegalStateException> {
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)
}
}
@@ -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<Pair<Long, Long>>()
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)
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<WorkingMemoryEntry.Summary>(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<MessageRecord.Error>(all[0])
assertEquals("e1", first.id)
assertEquals("boom", first.message)
assertEquals("E42", first.code)
assertEquals(t0, first.createdAt)
assertNull(assertIs<MessageRecord.Error>(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<MessageRecord.UserMessage>(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<MessageRecord.UserMessage>(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<WorkingMemoryEntry.User>(list[0].entry)
assertEquals(ctx, user.context)
}
}