feat(memory): add :memory-md-vector hybrid backend + :reflection-api + outbox/journal entity splits
- :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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -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
|
||||
}
|
||||
}
|
||||
+66
@@ -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)
|
||||
}
|
||||
+47
@@ -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(),
|
||||
)
|
||||
}
|
||||
+305
@@ -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")
|
||||
}
|
||||
}
|
||||
+166
@@ -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()
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
@@ -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()}"
|
||||
}
|
||||
+74
@@ -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() {}
|
||||
}
|
||||
+200
@@ -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].)
|
||||
*/
|
||||
+116
@@ -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() {}
|
||||
}
|
||||
+348
@@ -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"
|
||||
}
|
||||
+71
@@ -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")
|
||||
}
|
||||
}
|
||||
+82
@@ -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)
|
||||
}
|
||||
}
|
||||
+65
@@ -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
|
||||
}
|
||||
}
|
||||
+55
@@ -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()
|
||||
}
|
||||
+88
@@ -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
|
||||
}
|
||||
}
|
||||
+186
@@ -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)
|
||||
}
|
||||
}
|
||||
+107
@@ -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)
|
||||
}
|
||||
}
|
||||
+174
@@ -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)
|
||||
}
|
||||
}
|
||||
+372
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user