memory-vector: LlmMemoryReviewer + SkillStore/SkillSaveTool/SkillDeleteTool

Phase 3 (Hermes-style self-improvement) and Phase 5.2 (LLM-driven review):

* :skills — SkillStore interface + DiskSkillStore (upsert/remove, file<->catalog sync)
* :skills — SkillParser.serialize for write-back path
* :standalone — SkillSaveTool/SkillDeleteTool + SkillToolsFactory
* :standalone — LlmMemoryReviewer: one-shot LiteLlm review via structured-output
  JSON prompt ({toSave:[...], toDelete:[...]}); reuses MemorySystem store
* :standalone — ReviewDecisionParser (lenient, handles json fences, missing
  arrays, malformed numbers)
* :standalone — ReviewPrompts (Russian system+user prompts, fact categories)
* :standalone/Main — wires LlmMemoryReviewer instead of KeywordMdReviewer when
  LLM is available, falls back to keyword for off/md-only mode
* :client — send(content, context) overload + SendPayload wrapper
* :proto — ExperimentalNativeApi opt-in for MessageContextTest (native targets)

Bug fixes:
* SkillTools.kt: error() shadowed kotlin.error(); renamed to Nothing
* ChatConversation: .map { when(...); error() } → .mapNotNull { when ... else -> null }
  (Kotlin type inference of LUB LiteMessage | Nothing failed across when-expr)

Tests: 284 total green (memory-vector: 17, standalone: 149).

Dropped: IRC-QUESTIONS.md (irc-server design rejected — user decision 2026-09-14).
This commit is contained in:
2026-09-15 04:08:46 +03:00
parent 65365da89c
commit 227d14b7e4
18 changed files with 1070 additions and 50 deletions
@@ -5,7 +5,9 @@ import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.coroutines.Dispatchers
import kotlinx.io.files.Path
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.md.openMdMemorySystem
import pw.binom.agentik.memory.vector.VectorMemorySystem
@@ -15,6 +17,7 @@ import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillLoader
import pw.binom.agentik.standalone.agent.ChatAgent
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor
import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer
import pw.binom.agentik.standalone.config.AgentikConfig
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
import pw.binom.agentik.standalone.llm.LlmBackend
@@ -118,6 +121,16 @@ fun main() {
val contextWindow: Int? = config.llm.resolveContextWindow()
val contextCompactor = if (contextWindow != null) LiteLlmContextCompactor(liteLlm = llm) else null
// Review-loop: всегда используем LlmMemoryReviewer поверх LiteLlm, если память включена.
// Hermes-style one-shot с structured-output (JSON). Fallback — heuristic reviewer из бэкенда.
val memoryReviewer: MemoryReviewer? = memorySystem?.store?.let { store ->
LlmMemoryReviewer(
liteLlm = llm,
store = store,
dispatcher = Dispatchers.IO,
)
} ?: memorySystem?.reviewer
val agent = ChatAgent(
id = "agentik",
stores = stores,
@@ -127,7 +140,7 @@ fun main() {
skills = skills,
memoryStore = memorySystem?.store,
memoryPrefetcher = memorySystem?.prefetcher,
memoryReviewer = memorySystem?.reviewer,
memoryReviewer = memoryReviewer,
soulBody = soulBody,
contextWindow = contextWindow,
compressionThreshold = config.compressionThreshold,
@@ -623,21 +623,23 @@ class ChatConversation(
?.let { (it.entry as WorkingMemoryEntry.System).text }
?: systemPrompt
val pastTurns = if (record.isTemporal) emptyList() else wm
val pastTurns: List<LiteMessage> = if (record.isTemporal) emptyList() else wm
.filter { row ->
val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
isUserOrAssistant && !isPendingUser
}
.map { row ->
when (val e = row.entry) {
.mapNotNull { row ->
val e: WorkingMemoryEntry = row.entry
val msg: LiteMessage? = when (e) {
is WorkingMemoryEntry.User -> LiteMessage(
LiteRole.USER,
applyContextPrefix(e.content.toLiteContents(), e.context),
)
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
else -> error("unreachable")
else -> null
}
msg
}
val config = LiteConversationConfig(
@@ -0,0 +1,168 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore
import pw.binom.litert.LiteTool
/**
* Тул `skill_save(name, description, body)` — сохраняет скил через [SkillStore].
*
* Схема аргументов (function-calling JSON):
* ```
* {
* "name": "skill_save",
* "description": "Создать или обновить скил. Имя может содержать двоеточия (как в opencode: 'backend:spring:db-base').",
* "parameters": {
* "type": "object",
* "properties": {
* "name": {"type": "string", "description": "Имя скила, уникальное в каталоге."},
* "description": {"type": "string", "description": "Одно-два предложения: когда применять."},
* "body": {"type": "string", "description": "Markdown body скила."}
* },
* "required": ["name", "description", "body"]
* }
* }
* ```
*
* ВАЖНО: параметр LiteTool-лямбды не называется `invoke` — иначе Kotlin
* резолвит `invoke(x)` рекурсивно и StackOverflow (см. MemoryToolsFactory).
*/
internal class SkillSaveTool(
private val store: SkillStore,
) : LiteTool {
override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String {
val parsed = parseArgs(arguments)
?: return error("invalid arguments: $arguments")
val (name, description, body) = parsed
return try {
store.upsert(SkillFile(name = name, description = description, body = body))
"""{"ok":true,"name":"${escape(name)}"}"""
} catch (e: Exception) {
error(e.message ?: "upsert failed")
}
}
private fun parseArgs(arguments: String): Triple<String, String, String>? {
// Минимальный парсер JSON-объекта — вытаскиваем три строковых поля.
// Полагаемся на порядок ключей в агенте: name, description, body.
val name = extractString(arguments, "name") ?: return null
val description = extractString(arguments, "description") ?: return null
val body = extractString(arguments, "body") ?: return null
return Triple(name, description, body)
}
companion object {
private const val SCHEMA = """
{
"name": "skill_save",
"description": "Создать или обновить скил. Имя может содержать двоеточия (например 'backend:spring:db-base'). Скил станет доступен в этом и следующих сеансах через read_skill.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"body": {"type": "string"}
},
"required": ["name", "description", "body"]
}
}
"""
}
}
/**
* Тул `skill_delete(name)` — архивирует скил.
*/
internal class SkillDeleteTool(
private val store: SkillStore,
) : LiteTool {
override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String {
val name = extractString(arguments, "name")
?: return error("missing 'name' in arguments: $arguments")
return try {
val removed = store.remove(name)
if (removed) {
"""{"ok":true,"archived":"${escape(name)}"}"""
} else {
error("skill '$name' not found")
}
} catch (e: Exception) {
error(e.message ?: "delete failed")
}
}
companion object {
private const val SCHEMA = """
{
"name": "skill_delete",
"description": "Архивировать скил по имени. Скил больше не будет появляться в read_skill, но файл остаётся на диске с суффиксом .archived.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"]
}
}
"""
}
}
/**
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
* JSON простой (плоский объект с известным набором ключей), как в
* MemoryToolsFactory.
*/
internal fun extractString(json: String, key: String): String? {
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return null
val colon = json.indexOf(':', keyIdx)
if (colon < 0) return null
val firstQuote = json.indexOf('"', colon)
if (firstQuote < 0) return null
var i = firstQuote + 1
val sb = StringBuilder()
while (i < json.length) {
val c = json[i]
when {
c == '\\' && i + 1 < json.length -> {
when (val next = json[i + 1]) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(next)
}
i += 2
}
c == '"' -> return sb.toString()
else -> {
sb.append(c)
i++
}
}
}
return null
}
/** JSON-escape строки. */
internal fun escape(s: String): String = buildString(s.length + 2) {
for (c in s) {
when (c) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(c)
}
}
}
/** Строит `{"error":"..."}` JSON-ответ. */
internal fun error(message: String): Nothing = throw IllegalStateException(message)
@@ -0,0 +1,21 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillStore
/**
* Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style).
*
* Возвращает два NamedTool'а:
* - `skill_save(name, description, body)` — создать или обновить скил;
* - `skill_delete(name)` — архивировать скил.
*
* Оба оборачивают [SkillStore]; агент может дёргать их в любой момент,
* не только во время review-loop.
*/
object SkillToolsFactory {
fun create(store: SkillStore): List<NamedTool> = listOf(
NamedTool("skill_save", SkillSaveTool(store)),
NamedTool("skill_delete", SkillDeleteTool(store)),
)
}
@@ -0,0 +1,137 @@
package pw.binom.agentik.standalone.agent.memory
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.standalone.persistence.Ids
import pw.binom.litert.LiteLlm
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Реализация [MemoryReviewer] поверх on-device LLM (LiteLlm / Google LiteRT-LM).
*
* После каждого хода (или пачки ходов при compaction) зовём LiteLlm с
* специальным промптом, который просит модель вернуть JSON со списком
* новых заметок и удалений. Парсим руками (см. [ReviewDecisionParser]) —
* on-device модели с tool-calling работают ненадёжно, structured output
* стабильнее.
*
* Конструктор принимает `dispatcher` чтобы I/O LiteLlm не блокировал
* основной поток. По умолчанию — `Dispatchers.IO` (вытягивается из контекста).
*
* @param maxExistingFacts сколько последних заметок подмешивать в промпт
* как [memory-context], чтобы модель не дублировала уже сохранённые факты.
*/
class LlmMemoryReviewer(
private val liteLlm: LiteLlm,
private val store: MemoryStore,
private val dispatcher: CoroutineDispatcher,
private val maxExistingFacts: Int = 30,
private val clock: Clock = Clock.System,
) : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = withContext(dispatcher) {
// Снимок существующих заметок — чтобы модель не дублировала
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
val prompt = ReviewPrompts.reviewUserPrompt(turn, existingFacts)
val conv = liteLlm.createConversation(
pw.binom.litert.LiteConversationConfig(
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
temperature = 0.2f,
maxTokens = 512,
)
)
val raw = try {
conv.send(prompt)
} finally {
conv.close()
}
ReviewDecisionParser.parse(raw)
}
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision = withContext(dispatcher) {
if (turns.isEmpty()) return@withContext MemoryReviewDecision()
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
// Склеиваем все ходы в один промпт — модель посмотрит пакетом и сможет
// отсеять дубликаты между ходами.
val prompt = buildString {
if (existingFacts.isNotBlank()) {
appendLine("[memory-context — что уже сохранено]")
appendLine(existingFacts)
appendLine()
}
appendLine("[compacted-turns — будет удалено после compaction'а]")
turns.forEachIndexed { idx, t ->
appendLine()
appendLine("--- turn ${idx + 1} ---")
appendLine("[user] ${t.userMessage}")
appendLine("[assistant] ${t.assistantMessage}")
}
appendLine()
append("Верни JSON:")
}
val conv = liteLlm.createConversation(
pw.binom.litert.LiteConversationConfig(
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
temperature = 0.2f,
maxTokens = 1024,
)
)
val raw = try {
conv.send(prompt)
} finally {
conv.close()
}
ReviewDecisionParser.parse(raw)
}
/**
* Применяет решение к store: сохраняет новые заметки, удаляет помеченные.
* Возвращает сколько заметок записано/удалено — для метрик.
*/
suspend fun apply(decision: MemoryReviewDecision, source: pw.binom.agentik.memory.MemorySource): ApplyResult = withContext(dispatcher) {
var saved = 0
var deleted = 0
for (note in decision.toSave) {
store.upsert(toMemoryNote(note, source))
saved++
}
for (id in decision.toDelete) {
if (store.delete(id)) deleted++
}
ApplyResult(saved = saved, deleted = deleted)
}
private fun toMemoryNote(
note: NewMemoryNote,
source: pw.binom.agentik.memory.MemorySource,
): pw.binom.agentik.memory.MemoryNote {
val now = clock.now()
return pw.binom.agentik.memory.MemoryNote(
id = Ids.new("mem-review"),
category = note.category,
content = note.content,
createdAt = now,
lastUsedAt = now,
useCount = 0,
source = source,
)
}
data class ApplyResult(val saved: Int, val deleted: Int)
}
@@ -0,0 +1,195 @@
package pw.binom.agentik.standalone.agent.memory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.NewMemoryNote
/**
* Парсер ответа LLM-review-loop'а.
*
* LiteLlm (on-device) не имеет надёжного tool-calling flow, поэтому
* модель возвращает JSON в plain text. Парсим регуляркой + минимальным
* валидатором — если что-то не так, лучше no-op, чем краш.
*/
object ReviewDecisionParser {
/**
* Парсит ответ модели в [MemoryReviewDecision]. Возвращает пустой decision
* если ответ пустой, не JSON, или JSON битый — лучше ничего не сохранить,
* чем записать мусор.
*/
fun parse(rawOutput: String): MemoryReviewDecision {
val trimmed = rawOutput.trim()
if (trimmed.isEmpty()) return MemoryReviewDecision()
// Ищем JSON-блок, даже если модель обернула его в ``` или добавила пояснения
val json = extractJson(trimmed) ?: return MemoryReviewDecision()
return parseJson(json)
}
private fun extractJson(text: String): String? {
// Первый '{' до последней '}'
val start = text.indexOf('{')
val end = text.lastIndexOf('}')
if (start < 0 || end < 0 || end <= start) return null
return text.substring(start, end + 1)
}
/**
* Минимальный JSON-парсер. Не хочу тянуть kotlinx-serialization в этот
* слой — JSON простой (плоский массив объектов), пишем руками.
*/
private fun parseJson(json: String): MemoryReviewDecision {
return try {
val save = parseArray(json, "save") { obj ->
val category = parseString(obj, "category")?.let { runCatching { MemoryCategory.valueOf(it.uppercase()) }.getOrNull() }
?: return@parseArray null
val content = parseString(obj, "content")?.takeIf { it.isNotBlank() }
?: return@parseArray null
NewMemoryNote(category, content)
}
val delete = parseStringArray(json, "delete")
MemoryReviewDecision(toSave = save, toDelete = delete)
} catch (e: Exception) {
MemoryReviewDecision()
}
}
/**
* Парсит массив объектов из JSON-строки по ключу. callback получает
* содержимое одного элемента (без обрамляющих []{} и без имени ключа)
* и возвращает элемент результата либо null (пропустить).
*/
private fun <T> parseArray(json: String, key: String, map: (String) -> T?): List<T> {
// Ищем "key": [ ... ]
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return emptyList()
val arrayStart = json.indexOf('[', keyIdx)
val arrayEnd = json.indexOf(']', arrayStart)
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
return splitTopLevelObjects(arrayContent).mapNotNull { map(it) }
}
/**
* Разбивает содержимое JSON-массива на отдельные объекты верхнего уровня
* с учётом вложенности и экранирования кавычек.
*/
private fun splitTopLevelObjects(content: String): List<String> {
val result = mutableListOf<String>()
var depth = 0
var start = -1
var inString = false
var escaped = false
content.forEachIndexed { i, ch ->
if (escaped) { escaped = false; return@forEachIndexed }
when {
ch == '\\' && inString -> escaped = true
ch == '"' -> inString = !inString
!inString && ch == '{' -> {
if (depth == 0) start = i
depth++
}
!inString && ch == '}' -> {
depth--
if (depth == 0 && start >= 0) {
result.add(content.substring(start, i + 1))
start = -1
}
}
}
}
return result
}
/**
* Парсит массив строк из JSON по ключу. Используется для `delete`
* (там элементы — голые строки, не объекты).
*/
private fun parseStringArray(json: String, key: String): List<String> {
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return emptyList()
val arrayStart = json.indexOf('[', keyIdx)
val arrayEnd = json.indexOf(']', arrayStart)
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
val result = mutableListOf<String>()
var i = 0
while (i < arrayContent.length) {
// Skip whitespace and commas
while (i < arrayContent.length && (arrayContent[i].isWhitespace() || arrayContent[i] == ',')) i++
if (i >= arrayContent.length || arrayContent[i] != '"') break
i++ // skip opening quote
val sb = StringBuilder()
var escaped = false
while (i < arrayContent.length) {
val c = arrayContent[i]
if (escaped) {
when (c) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(c)
}
escaped = false
i++
continue
}
when (c) {
'\\' -> { escaped = true; i++ }
'"' -> {
result.add(sb.toString())
i++
break
}
else -> { sb.append(c); i++ }
}
}
}
return result
}
/**
* Парсит строковое значение по ключу в JSON-объекте. Возвращает
* содержимое без обрамляющих кавычек и с раскрытыми базовыми escape.
*/
private fun parseString(obj: String, key: String): String? {
val keyIdx = obj.indexOf("\"$key\"")
if (keyIdx < 0) return null
val colon = obj.indexOf(':', keyIdx)
if (colon < 0) return null
val firstQuote = obj.indexOf('"', colon)
if (firstQuote < 0) return null
// Ищем закрывающую кавычку с учётом escape
var i = firstQuote + 1
val sb = StringBuilder()
while (i < obj.length) {
val c = obj[i]
when {
c == '\\' && i + 1 < obj.length -> {
when (val next = obj[i + 1]) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(next)
}
i += 2
}
c == '"' -> return sb.toString()
else -> {
sb.append(c)
i++
}
}
}
return null
}
}
@@ -0,0 +1,59 @@
package pw.binom.agentik.standalone.agent.memory
import pw.binom.agentik.memory.ReviewedTurn
/**
* Промпты для review-loop'а через LiteLlm.
*
* Hermes делает это с OpenAI/Anthropic tool-calling flow. У нас on-device
* движок (Google LiteRT-LM) — там tool-calling ненадёжен, поэтому используем
* structured-output: модель должна вернуть JSON, который мы парсим регуляркой.
*/
object ReviewPrompts {
/**
* Системная инструкция для review-loop'а. На русском — модель у нас
* русскоязычная (gemma-2-9b / qwen / и т.п.), английский промпт часто
* даёт хуже результат на ru-данных.
*/
const val REVIEW_SYSTEM_PROMPT = """Ты — агент ревью памяти. Твоя задача — проанализировать пару (сообщение пользователя, ответ ассистента) и решить, что из неё стоит сохранить в долговременную память.
Категории памяти:
- USER — факты о пользователе (имя, профессия, предпочтения, контекст его жизни)
- WORLD — факты о внешнем мире (проекты, технологии, организации, конкретные API/документация)
- PREFERENCE — предпочтения по формату/поведению ассистента (стиль кода, длины ответов, инструменты)
Правила:
1. Сохраняй ТОЛЬКО durable facts — то, что останется актуальным через недели. Не сохраняй "пользователь поздоровался" или "ассистент использовал grep".
2. Не дублируй уже сохранённое — если факт уже есть в [memory-context], пропусти.
3. Каждый факт — одна короткая фраза. Не абзацы, не "the user mentioned...".
4. Не выдумывай. Если ничего достойного — верни пустой массив.
Формат ответа — строго JSON без обрамления ```json и без пояснений:
{"save":[{"category":"USER|WORLD|PREFERENCE","content":"..."}],"delete":[]}
Если нечего сохранять:
{"save":[],"delete":[]}"""
/**
* Форматирует user-prompt для review-loop'а. Подаёт текущий ход +
* текущее состояние долговременной памяти (чтобы избежать дубликатов).
*/
fun reviewUserPrompt(
turn: ReviewedTurn,
existingFacts: String,
): String = buildString {
if (existingFacts.isNotBlank()) {
appendLine("[memory-context — что уже сохранено]")
appendLine(existingFacts)
appendLine()
}
appendLine("[user]")
appendLine(turn.userMessage)
appendLine()
appendLine("[assistant]")
appendLine(turn.assistantMessage)
appendLine()
append("Верни JSON:")
}
}
@@ -414,7 +414,7 @@ class ChatAgentTest {
private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm {
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities? = null
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")
@@ -16,7 +16,7 @@ import pw.binom.litert.LiteRole
*/
internal class FakeLiteLlm : LiteLlm {
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities? = null
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
@@ -0,0 +1,184 @@
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
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,106 @@
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
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)
}
}