Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s

This commit is contained in:
2026-09-23 03:48:38 +03:00
parent 84f5fd84f3
commit e447525059
105 changed files with 2732 additions and 1978 deletions
@@ -29,7 +29,7 @@ class LlmReflector(
private val llm: LiteLlm,
val maxTurns: Int = 6,
private val maxTokens: Int = 512,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val clock: Clock = Clock.System,
) {
/**
@@ -1,78 +0,0 @@
package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
private val log = mu.KotlinLogging.logger {}
/**
* Фоновый минер скилов (skill mining): сетка безопасности для skill self-improvement.
*
* Модель в ходе разговора может "протупить" и не вызвать `skill_save`, хотя приём
* был действительно переиспользуемым. [SkillMiner] периодически (каждые N ходов,
* см. `AGENTIK_SKILL_MINING_INTERVAL`) берёт последние ходы диалога, показывает их
* LLM вместе с текущим каталогом скилов и просит structured-output JSON:
* {"skills": [{"name","description","body"}]}. Найденные скилы upsert-ятся в
* [pw.binom.agentik.skills.SkillStore] — агент становится умнее между сессиями
* даже без прямого tool-call в ходе разговора.
*
* Архитектурно — точный аналог [LlmReflector]: короткоживущий LiteConversation
* (один прогон = один LLM-вызов), blocking-инференс на [dispatcher], defensive
* парсинг [SkillMiningParser] (кривой ответ → пустой список, не ломает agent loop).
*
* @param llm LLM-бэкенд
* @param maxTurns сколько последних ходов передавать модели (default 30)
* @param maxTokens потолок ответа модели (default 1536 — body скила бывает длинным)
* @param dispatcher диспетчер для блокирующего LLM-вызова
*/
class SkillMiner(
private val llm: LiteLlm,
val maxTurns: Int = 30,
private val maxTokens: Int = 1536,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* Mine по последним [turns] с учётом текущего каталога [existing].
*
* @return найденные/обновлённые скилы; пустой список — нечего сохранять
* или модель ответила мусором (defensive: прогон просто пропускается).
*
* Вызов блокирующий: ~1-3 с на CPU для on-device LiteRT-LM. Всегда вызывать
* из background scope (хук [pw.binom.agentik.standalone.agent.ChatConversation]
* или debug-эндпоинт).
*/
suspend fun mine(turns: List<ConversationTurn>, existing: List<SkillFile>): List<SkillFile> =
withContext(dispatcher) {
val batch = turns.takeLast(maxTurns)
if (batch.isEmpty()) return@withContext emptyList()
val conversation = llm.createConversation(
config = LiteConversationConfig(
systemInstruction = SkillMiningPrompts.SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
temperature = 0.2f,
maxTokens = maxTokens,
)
)
try {
val userPrompt = SkillMiningPrompts.buildUserPrompt(batch, existing)
val raw = conversation.send(userPrompt)
val mined = SkillMiningParser.parse(raw)
if (mined.isNotEmpty()) {
log.info { "skill-mine: found ${mined.size} skill(s) from ${batch.size} turns: ${mined.map { it.name }}" }
}
mined
} catch (e: Throwable) {
log.warn(e) { "skill-mine: LLM call failed, skipping pass" }
emptyList()
} finally {
conversation.close()
}
}
}
@@ -1,207 +0,0 @@
package pw.binom.agentik.llm.tools
import pw.binom.agentik.skills.SkillFile
/**
* Минимальный парсер JSON-ответа [SkillMiner] (в том же defensive-стиле, что
* [ReflectionParser] и [ReviewDecisionParser]: без kotlinx-serialization, чтобы
* кривой ответ локальной модели не ронял agent loop).
*
* Ожидаемая форма:
* ```
* {"skills": [{"name": "x", "description": "...", "body": "..."}]}
* ```
* Допуски:
* - ответ может быть обёрнут в ```json ... ``` fences;
* - может быть текст до/после JSON — ищем первый `{` (или `[`) и балансируем;
* - допустим и голый массив `[{...}, {...}]` без ключа `"skills"`;
* - `body` может содержать `\n`, `\"`, `\\` — деэскейпим;
* - `description`/`body` могут отсутствовать (тогда пустые);
* - любой мусор (невалидные кавычки, незакрытые скобки) → пустой список,
* mining-прогон просто не сохранит ничего.
*/
object SkillMiningParser {
fun parse(raw: String): List<SkillFile> {
val blob = extractJsonBlob(raw) ?: return emptyList()
val array = extractSkillsArray(blob) ?: return emptyList()
return splitTopLevelObjects(array).mapNotNull { obj ->
val name = extractStringField(obj, "name")?.trim().orEmpty()
if (name.isEmpty()) return@mapNotNull null
val description = extractStringField(obj, "description")?.trim().orEmpty()
val body = extractStringField(obj, "body")?.trim().orEmpty()
SkillFile(name = name, description = description, body = body)
}
}
/**
* Обрезает ``` fences, находит первый `{` или `[` и возвращает подстроку
* до парной закрывающей (со знанием состояний string/escape).
*/
internal fun extractJsonBlob(raw: String): String? {
var s = raw.trim()
if (s.startsWith("```")) {
val nl = s.indexOf('\n')
if (nl > 0) s = s.substring(nl + 1)
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
}
val open = minOf(
s.indexOf('{').takeIf { it >= 0 } ?: Int.MAX_VALUE,
s.indexOf('[').takeIf { it >= 0 } ?: Int.MAX_VALUE,
)
if (open == Int.MAX_VALUE) return null
val opener = s[open]
val closer = if (opener == '{') '}' else ']'
var depth = 0
var inString = false
var escape = false
for (i in open until s.length) {
val c = s[i]
if (escape) { escape = false; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
continue
}
when (c) {
'"' -> inString = true
opener, '{', '[' -> depth++
'}', ']' -> {
depth--
if (depth == 0 && ((c == closer) || (opener == '{' && c == '}') || (opener == '[' && c == ']'))) {
return s.substring(open, i + 1)
}
}
}
}
return null
}
/**
* Находит массив скилов: если в blob есть ключ `"skills"` — массив после него,
* иначе сам blob (если начинается с `[`).
*/
internal fun extractSkillsArray(blob: String): String? {
val keyIdx = blob.indexOf("\"skills\"")
if (keyIdx >= 0) {
val colon = blob.indexOf(':', keyIdx + "skills".length + 2)
if (colon < 0) return null
val open = blob.indexOf('[', colon + 1)
if (open < 0) return null
return balanceArray(blob, open)
}
if (blob.startsWith('[')) return blob.drop(1).dropLast(1)
return null
}
/** Балансирует `[...]` от [start] (включительно). Возвращает содержимое без скобок. */
private fun balanceArray(s: String, start: Int): String? {
var depth = 0
var inString = false
var escape = false
for (i in start until s.length) {
val c = s[i]
if (escape) { escape = false; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
continue
}
when (c) {
'"' -> inString = true
'[' -> depth++
']' -> {
depth--
if (depth == 0) return s.substring(start + 1, i)
}
}
}
return null
}
/** Разбивает содержимое массива на топ-уровневые `{...}` объекты (string-aware). */
internal fun splitTopLevelObjects(arrayContent: String): List<String> {
val out = mutableListOf<String>()
var i = 0
while (i < arrayContent.length) {
if (arrayContent[i] == '{') {
var depth = 0
var inString = false
var escape = false
var j = i
while (j < arrayContent.length) {
val c = arrayContent[j]
if (escape) { escape = false; j++; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
} else {
when (c) {
'"' -> inString = true
'{' -> depth++
'}' -> {
depth--
if (depth == 0) {
out.add(arrayContent.substring(i, j + 1))
i = j + 1
break
}
}
}
}
j++
}
if (j >= arrayContent.length) break
} else {
i++
}
}
return out
}
/**
* Достаёт первое строковое поле `"<name>": "..."` из JSON-объекта с
* поддержкой escapes (`\"`, `\\`, `\n`, `\t`). `null` если поля нет.
*/
internal fun extractStringField(obj: String, name: String): String? {
val keyRe = Regex(""""$name"\s*:""")
val keyMatch = keyRe.find(obj) ?: return null
val colonIdx = keyMatch.range.last
// Пропускаем пробельные символы после :
var i = colonIdx + 1
while (i < obj.length && (obj[i] == ' ' || obj[i] == '\n' || obj[i] == '\r' || obj[i] == '\t')) i++
if (i >= obj.length || obj[i] != '"') {
// Значение не строка (null/число) — не поддерживаем.
return null
}
i++ // открывающая кавычка
val sb = StringBuilder()
while (i < obj.length) {
val c = obj[i]
if (c == '\\' && i + 1 < obj.length) {
when (val esc = obj[i + 1]) {
'"' -> sb.append('"')
'\\' -> sb.append('\\')
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
else -> sb.append(esc)
}
i += 2
} else if (c == '"') {
return sb.toString()
} else {
sb.append(c)
i++
}
}
// Незакрытая строка — мусор от модели, считаем null.
return null
}
}
@@ -1,80 +0,0 @@
package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile
/**
* Промпты для [SkillMiner]: structured-output JSON.
*
* Тот же подход, что [ReflectionPrompts] и [LlmMemoryReviewer]: локальной модели
* (LiteRT-LM) не доверяем tool-calling, поэтому просим строго JSON и парсим руками
* ([SkillMiningParser]).
*/
object SkillMiningPrompts {
/**
* System prompt. Задаёт роль "минёра скилов": модель смотрит на последние
* ходы разговора и решает, есть ли в них переиспользуемый приём, который
* стоит закрепить в скиле, чтобы агент становился умнее между сессиями.
*
* Ключевое отличие от `skill_save` (в-ходе): mining — это сетка безопасности,
* если модель в ходе разговора забыла сохранить скил. Поэтому промпт жёсткий
* по критериям: только реально переиспользуемое, без дублей каталога.
*/
val SYSTEM_PROMPT: String = """
Ты — фоновый минер навыков (skill miner) для ИИ-агента.
Тебе показывают последние ходы разговора агента с пользователем и каталог
его текущих навыков (скилов). Твоя задача — найти в этих ходах переиспользуемый
приём, процедуру или паттерн, который агент применит в БУДУЩИХ, других
разговорах. Такой приём закрепляется как скил, и агент с ним становится
умнее.
Критерии, ЧТО сохращать:
- конкретная воспроизводимая процедура (шаги, команды, форматы, приёмы);
- решение проблемы, которое модель выработала и в другом разговоре повторит;
- проверка/валидация, о которой модель "забыла" и её стоит закрепить.
ЧТО НЕ сохраняй:
- разовые факты конкретного разговора (это не приём, а данные);
- тривиальность ("ответь кратко"), которую и так видно из контекста;
- дубли уже существующих скилов в каталоге — если приём уже есть,
предложи ОБНОВЛЕНИЕ (то же имя, улучшённый body), а не новый скил.
Вывод — строго JSON, без пояснений до и после:
{"skills": [{"name": "...", "description": "...", "body": "..."}]}
Если сохранять нечего — {"skills": []}
Правила по полям:
- name: kebab-case или иерархия через двоеточие (например, "backend:spring:db-base");
короткое, 2-5 слов. Если обновляешь существующий скил — ТОЧНО его имя.
- description: 1-2 предложения, когда/зачем применять скил.
- body: markdown-инструкция: краткое описание + нумерованные шаги + примеры.
Только то, что агент должен помнить; без воды.
""".trimIndent()
/**
* User prompt: последние [turns] разговора + каталог существующих скилов.
* Ходы нумеруются, чтобы модель понимала хронологию.
*/
fun buildUserPrompt(turns: List<ConversationTurn>, existing: List<SkillFile>): String = buildString {
appendLine("### Последние ходы разговора (по хронологии)")
turns.forEachIndexed { i, t ->
appendLine()
appendLine("--- Ход ${i + 1} ---")
appendLine("[user] ${t.userMessage.take(1500)}")
appendLine("[assistant] ${t.assistantMessage.take(2000)}")
}
appendLine()
appendLine("### Текущий каталог скилов (не дубли, обновляй при необходимости)")
if (existing.isEmpty()) {
appendLine("(пока нет)")
} else {
for (s in existing) {
appendLine("- ${s.name}: ${s.description.take(160)}")
}
}
appendLine()
appendLine("Если есть что сохранить (или обновить существующий) — выведи JSON. Иначе {\"skills\": []}.")
}
}
@@ -0,0 +1,127 @@
package pw.binom.agentik.llm.tools
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, String>>) : 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 var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
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 replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
}
override fun sendContents(contents: List<LiteContentPart>): String {
parent.lastContents = contents
return parent.reply
}
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
LiteDelta(text = "", isDone = true)
override fun close() {}
}
@@ -0,0 +1,71 @@
package pw.binom.agentik.llm.tools
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,186 @@
package pw.binom.agentik.llm.tools.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.llm.tools.FakeLiteLlm
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
import pw.binom.agentik.llm.tools.ReviewPrompts
class LlmMemoryReviewerTest {
/**
* Минимальный in-memory store для тестов — реализует [MemoryStore],
* хранит заметки в MutableList, поддерживает events flow.
*/
private class InMemoryStore : MemoryStore {
private val notes = mutableMapOf<String, MemoryNote>()
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
override suspend fun upsert(note: MemoryNote) {
notes[note.id] = note
_events.emit(MemoryStoreEvent.Upserted(note))
}
override suspend fun get(id: String): MemoryNote? = notes[id]
override suspend fun list(
category: MemoryCategory?,
conversationId: String?,
limit: Int,
offset: Int,
): List<MemoryNote> = notes.values
.filter { category == null || it.category == category }
.filter { conversationId == null || it.conversationId == conversationId }
.sortedByDescending { it.lastUsedAt }
.drop(offset)
.take(limit)
override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List<pw.binom.agentik.memory.MemorySearchResult> = emptyList()
override suspend fun delete(id: String): Boolean = notes.remove(id) != null
override suspend fun markUsed(id: String, at: Instant) {
notes[id]?.let {
notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1)
}
}
override fun events(): kotlinx.coroutines.flow.Flow<MemoryStoreEvent> = _events
override fun close() {}
// Helper for tests to seed notes
fun seed(note: MemoryNote) {
notes[note.id] = note
}
}
@Test
fun `review parses save JSON and applies upsert`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}"""
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(
ReviewedTurn(
userMessage = "Меня Саша зовут",
assistantMessage = "Приятно познакомиться, Саша!",
)
)
assertEquals(1, decision.toSave.size)
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(1, applied.saved)
val all = store.list()
assertEquals(1, all.size)
assertEquals("Имя — Саша", all[0].content)
assertEquals(MemorySource.AUTO_REVIEW, all[0].source)
}
@Test
fun `review applies delete decisions`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[],"delete":["mem-stale-1"]}"""
}
val store = InMemoryStore().apply {
seed(
MemoryNote(
id = "mem-stale-1",
category = MemoryCategory.USER,
content = "stale",
createdAt = Instant.parse("2026-01-01T00:00:00Z"),
lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"),
useCount = 0,
source = MemorySource.AUTO_REVIEW,
)
)
}
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("удали это", "ок"))
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(0, applied.saved)
assertEquals(1, applied.deleted)
assertEquals(0, store.list().size)
}
@Test
fun `review returns empty decision when LLM produces garbage`() = runTest {
val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." }
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
assertTrue(decision.toSave.isEmpty())
assertTrue(decision.toDelete.isEmpty())
}
@Test
fun `review handles empty LLM reply`() = runTest {
val llm = FakeLiteLlm().apply { reply = "" }
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
assertTrue(decision.toSave.isEmpty())
}
@Test
fun `review creates conversation with review system prompt`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[],"delete":[]}"""
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
reviewer.review(ReviewedTurn("u", "a"))
assertNotNull(llm.lastConfig)
assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction)
}
@Test
fun `reviewPreCompaction processes batch of turns`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """
{"save":[
{"category":"USER","content":"Работает в Яндексе"},
{"category":"WORLD","content":"JVector — pure-Java ANN"}
],"delete":[]}
""".trimIndent()
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val turns = listOf(
pw.binom.agentik.memory.ConversationTurn(
userMessage = "Я в Яндексе работаю",
assistantMessage = "Круто!",
),
pw.binom.agentik.memory.ConversationTurn(
userMessage = "А что за JVector?",
assistantMessage = "ANN-библиотека на Java.",
),
)
val decision = reviewer.reviewPreCompaction(turns)
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(2, applied.saved)
assertEquals(2, store.list().size)
}
}
@@ -0,0 +1,107 @@
package pw.binom.agentik.llm.tools.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)
}
}