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
+7 -1
View File
@@ -18,4 +18,10 @@ out/
# Local tooling (Magic Context, IDE plugins, MCP configs)
.cortexkit/
.veai/
.veai/
# Runtime / test artifacts
agentik.db
agentik.db-shm
agentik.db-wal
memory-md/agentik-mem-*/
-40
View File
@@ -1,40 +0,0 @@
# IRC-транспорт — открытые вопросы
По мере закрытия отмечаем `- N. [x]`. Закрытый вопрос остаётся в файле с принятым решением.
- 1. [x] **История.** Принято: новый абстрактный метод `suspend fun getLatestMessages(offset: Int, limit: Int): List<Message>` в `:proto.Conversation` (offset = пропустить С КОНЦА, 0 = самые свежие). IRC-сервер не держит своего буфера, на `CHATHISTORY` дёргает агента. CAP `draft/chathistory` объявляем.
- 2. [x] **Tool/Error/Image события — раскладка по IRC.** Принято. Каждый `Event` мапится:
- `StartReasoning` → дропаем с провода
- `StartResponse(TEXT|IMAGE)` → CTCP `AGENTIK response-start {"type":"text"|"image"}`
- `AppendText(body)` → `PRIVMSG #chan :body`
- `AppendImage(body, mime)` → через `ImageStore` → CTCP `AGENTIK image {"url":..,"mime":..,"ttl":..}`
- `ToolCall` → CTCP `AGENTIK tool-call {json}`
- `ToolResult` → CTCP `AGENTIK tool-result {json}`
- `Error` → CTCP `AGENTIK error {json}`
- `End` → CTCP `AGENTIK end`
- `Interrupted` → CTCP `AGENTIK interrupted`
- 3. [x] **Interrupt.** **Упрощение:** команды `/stop` и `/interrupt` в `PRIVMSG` (т.е. `PRIVMSG #chan :/stop`) вызывают `Conversation.interrupt()`. Если `PRIVMSG` приходит во время активного размышления — сервер сначала зовёт `interrupt()`, затем `send(content)`. CTCP-вариант дропаем.
- 4. [x] **`AgentEvent.Created/Deleted/Renamed` маппинг.** Принято: Created = IRC `JOIN`-бродкаст; Deleted = `KICK` самого себя; Renamed = `TOPIC #foo :new title`.
- 5. [x] **NICK агента.** Принято: параметр в DSL, дефолт `"Agent"`.
- 6. [x] **Multi-user в канале.** Принято: **в канале всегда только наш агент и наш пользователь. Других не будет никогда.**
- 7. [x] **Маппинг канал ↔ Conversation.** Принято: имя IRC-канала = `Conversation.title`; `Conversation.id` = UUID, выдаётся через `CTCP AGENTIK id #foo`; при переименовании канала id стабилен.
- 8. [x] **Создание канала.** Принято: `JOIN #foo` → создаём `Conversation(title="foo", id=<uuid>)`. Если уже есть — заходим.
- 9. [x] **Удаление канала.** Принято: `PART` закрывает сторону клиента; `CTCP AGENTIK delete #foo` — удаление Conversation-а.
- 10. [x] **Модуль.** Принято: `:irc-server`, KMP через kotlinx-io. Также модуль содержит HTTP staging-эндпоинт для картинок (см. п.13).
- 11. [x] **Аутентификация клиента.** Принято: без auth, любой может подключиться.
- 12. [x] **Capabilities (ircv3).** Принято: в первом проходе объявляем `server-time`, `message-tags`, `batch`, `draft/chathistory`. SASL не объявляем (п.11). Остальные CAPs (echo-message, labeled-response, standard-replies, multi-user stuff) добавляем инкрементально.
- 13. [x] **ImageStore.** Принято: `ImageStore` живёт в `:irc-server`, дефолтная in-memory реализация с **TTL 600 сек**, staging-порт **авто-pick** (0 → свободный). Клиент через IRC картинки **не шлёт** (для этого HTTP `:server`). Конкретную реализацию `ImageStore` пользователь сделает позже сам, в первом проходе — наша in-memory.
## Все вопросы закрыты
Итого решений по `:irc-server`:
- Модуль `:irc-server`, KMP через kotlinx-io.
- Канал IRC = `Conversation` (имя = title, UUID через CTCP `AGENTIK id`).
- В канале всегда только 1 пользователь + агент (ник `Agent` по умолчанию).
- `Event` → IRC: `PRIVMSG` для текста, CTCP `AGENTIK <имя> {json}` для всего остального. `StartReasoning` дропается.
- Interrupt через `/stop` / `/interrupt` в PRIVMSG; входящий PRIVMSG во время размышления = `interrupt()` + `send()`.
- История через `CHATHISTORY` (LATEST/BEFORE/BETWEEN/AFTER), сервер не буферизует, дёргает новый `:proto` метод `getLatestMessages(offset, limit)`.
- Картинки только agent → client через `ImageStore` + HTTP staging в том же модуле.
- CAPs: `server-time`, `message-tags`, `batch`, `draft/chathistory`. Без auth.
Можно кодить.
@@ -13,10 +13,12 @@ import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant
/**
@@ -58,10 +60,10 @@ internal class ConversationClient(
snapshot = updated
}
override suspend fun send(content: List<Content>) {
override suspend fun send(content: List<Content>, context: MessageContext?) {
httpClient.post("$convUrl/messages") {
contentType(ContentType.Application.Json)
setBody(content)
setBody(SendPayload(content, context))
}
}
@@ -92,3 +94,9 @@ internal class ConversationClient(
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
}
}
@Serializable
private data class SendPayload(
val content: List<Content>,
val context: MessageContext? = null,
)
@@ -78,6 +78,7 @@ class MessageContextTest {
assertEquals(ctx.metadata, decoded.metadata)
}
@kotlin.experimental.ExperimentalNativeApi
@Test
fun `origin field name is origin in JSON`() {
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "boot")
@@ -129,6 +129,38 @@ object SkillParser {
private fun String.stripBom(): String =
if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this
/**
* Сериализует [skill] в формат SKILL.md (YAML-фронтматтер + markdown body).
* Используется [SkillStore] для записи скилов на диск.
*
* Формат:
* ```
* ---
* name: backend:spring:db-base
* description: Use when ...
* ---
*
* # Body markdown...
* ```
*/
fun serialize(skill: SkillFile): String {
val body = skill.body.trimStart('\n').trimEnd('\n')
val yamlBody = yaml.encodeToString(
Frontmatter.serializer(),
Frontmatter(name = skill.name, description = skill.description),
)
return buildString {
append("---\n")
append(yamlBody)
append("---\n")
if (body.isNotEmpty()) {
append("\n")
append(body)
append("\n")
}
}
}
/**
* kaml кидает [com.charleskorn.kaml.YamlException] и на синтаксические
* ошибки YAML, и на отсутствие обязательных полей. Различаем по сообщению:
@@ -0,0 +1,31 @@
package pw.binom.agentik.skills
/**
* Хранилище скилов с операциями upsert/remove.
*
* Реализации обязаны:
* - `upsert` — записать скил в постоянное хранилище (диск) и сделать его
* доступным в [catalog] сразу же после вызова;
* - `remove(name)` — пометить скил как archived (убрать из активного [catalog])
* и вернуть `true`, если такой скил был; `false` — если не нашли;
* - быть потокобезопасными для конкурентных вызовов из нескольких корутин.
*
* Это контракт, через который SkillSaveTool / SkillDeleteTool общаются с
* диском. Без этого skill self-improvement в Hermes-стиле был бы невозможен.
*/
interface SkillStore : AutoCloseable {
/** Текущий каталог активных (не archived) скилов. */
val catalog: SkillCatalog
/**
* Создаёт или обновляет скил. Имя скила может содержать `:` (как в
* opencode: `backend:spring:db-base`); маппинг на файловую систему —
* забота реализации.
*/
fun upsert(skill: SkillFile)
/** Архивирует скил по имени: убирает из [catalog], но не удаляет файл. */
fun remove(name: String): Boolean
override fun close()
}
@@ -0,0 +1,97 @@
package pw.binom.agentik.skills
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* JVM-реализация [SkillStore] поверх папки скилов.
*
* Конвенция путей:
* - `click-on` → `skills/click-on/SKILL.md` (одна директория);
* - `backend:spring:db-base` → `skills/backend/spring/db-base/SKILL.md` — все сегменты
* после первого становятся вложенными директориями.
*
* Архивирование:
* - при [remove] файл переименовывается в `<original>.archived` и больше не
* попадает в [catalog]. Это позволяет восстановить скил, если нужно.
*
* Потокобезопасность: все операции защищены одним [ReentrantLock] — write-tool
* может зваться из корутины, читатели каталога — из других.
*/
class DiskSkillStore(
private val dir: File,
) : SkillStore {
private val lock = ReentrantLock()
// Кэш: активные скилы по имени. На старте заполняется из диска, потом
// обновляется по мере upsert/remove.
private val active: MutableMap<String, SkillFile> = linkedMapOf()
init {
if (!dir.exists()) dir.mkdirs()
// При старте — перечитываем каталог, пропуская .archived файлы.
val loadResult = SkillLoader.loadDirectory(dir)
for (skill in loadResult.catalog.skills) {
active[skill.name] = skill
}
}
override val catalog: SkillCatalog
get() = lock.withLock { SkillCatalog(active.values.toList()) }
override fun upsert(skill: SkillFile) {
require(skill.name.isNotBlank()) { "skill name must not be blank" }
require(skill.description.isNotBlank()) { "skill description must not be blank" }
lock.withLock {
val target = skillFile(skill.name)
target.parentFile?.mkdirs()
target.writeText(SkillParser.serialize(skill), Charsets.UTF_8)
active[skill.name] = skill
}
}
override fun remove(name: String): Boolean = lock.withLock {
val existing = active.remove(name) ?: return@withLock false
val target = skillFile(name)
if (target.exists()) {
val archived = File(target.parentFile, target.name + ".archived")
// Если архивная копия уже есть — дописываем суффикс
val finalArchived = if (archived.exists()) {
var i = 1
var f: File
do {
f = File(target.parentFile, "${target.name}.archived.$i")
i++
} while (f.exists())
f
} else archived
target.renameTo(finalArchived)
}
true
}
override fun close() {
// Никаких ресурсов: всё на диске. Просто no-op.
}
/**
* Маппинг имени на файловый путь:
* `backend:spring:db-base` → `dir/backend/spring/db-base/SKILL.md`
* `click-on` → `dir/click-on/SKILL.md`
*/
private fun skillFile(name: String): File {
val segments = name.split(':').map { sanitize(it) }
require(segments.isNotEmpty() && segments.all { it.isNotEmpty() }) {
"invalid skill name '$name'"
}
val path = File(dir, segments.joinToString("/"))
return File(path, "SKILL.md")
}
/** Убираем из сегментов пути всё кроме `[A-Za-z0-9_-]`. */
private fun sanitize(s: String): String =
s.filter { it.isLetterOrDigit() || it == '_' || it == '-' }
.ifEmpty { "_" }
}
@@ -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)
}
}