Phase 5: :memory-vector (JVector + SQLite + LLM-эмбеддинги), memory-abstraction, compaction, MessageContext

- :memory-api — общий контракт MemoryStore/Prefetcher/Reviewer/Tools/MemorySystem
- :memory-md (KMP, kotlinx-io) — Hermes-style §-файлы, keyword overlap
- :memory-vector (JVM-only) — JVector ANN + SQLite + HttpEmbeddingClient
- :standalone — AGENTIK_MEMORY_BACKEND={md,vector,off}, выбор в Main.kt
- :standalone — compaction рабочего контекста (LiteLlmContextCompactor + reviewPreCompaction)
- :proto — MessageContext (origin: user/system/event) на send и в Message
- :server — backward-compat dual-format для POST /messages
- README — env-vars, vector-бэкенд docs
This commit is contained in:
2026-09-15 03:44:15 +03:00
parent 0faad45f3d
commit 65365da89c
71 changed files with 5856 additions and 142 deletions
+29
View File
@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
// Чистый KMP commonMain — модели и интерфейсы памяти, без платформенного IO.
// Зеркалит набор :proto / :server. Конкретные бэкенды (MD, SQLite+vector)
// живут в отдельных модулях и могут таргетить только нужное подмножество.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
@@ -0,0 +1,20 @@
package pw.binom.agentik.memory
/**
* Категория факта в долговременной памяти.
*
* - [USER] — о пользователе (кто он, чем занимается, привычки).
* - [WORLD] — о мире/проектах (стек, инструменты, люди, окружение).
* - [PREFERENCE] — как пользователь хочет, чтобы агент работал.
*/
enum class MemoryCategory(val id: String) {
USER("user"),
WORLD("world"),
PREFERENCE("preference");
companion object {
fun fromId(id: String): MemoryCategory =
entries.firstOrNull { it.id == id }
?: throw IllegalArgumentException("unknown memory category: $id")
}
}
@@ -0,0 +1,27 @@
package pw.binom.agentik.memory
import kotlin.time.Instant
/**
* Одна запись в долговременной памяти агента.
*
* @property id уникальный идентификатор (`mem-<uuid>` по умолчанию).
* @property category категория факта.
* @property content полный текст заметки (одно-два предложения на практике).
* @property createdAt время создания.
* @property lastUsedAt когда последний раз заметка выдавалась в prefetch.
* @property useCount сколько раз выдавалась в prefetch (для ранжирования).
* @property conversationId если не null — заметка привязана к конкретному диалогу;
* null — глобальная (дефолт).
* @property source как попала в память.
*/
data class MemoryNote(
val id: String,
val category: MemoryCategory,
val content: String,
val createdAt: Instant,
val lastUsedAt: Instant,
val useCount: Int = 0,
val conversationId: String? = null,
val source: MemorySource,
)
@@ -0,0 +1,20 @@
package pw.binom.agentik.memory
/**
* Recall: достать релевантные факты для следующего хода (например — последнее
* сообщение пользователя). Результат инжектится в user-message-префикс
* контекстным блоком перед отправкой в LLM.
*
* Бэкенды могут реализовать как keyword-search (MD), так и семантический
* поиск по эмбеддингам (vector-store).
*
* Метод обязан вызывать [MemoryStore.markUsed] для каждой выданной заметки
* (если хочет корректный учёт recency/useCount).
*/
interface MemoryPrefetcher {
suspend fun prefetch(
query: String,
topK: Int = 10,
category: MemoryCategory? = null,
): List<MemoryNote>
}
@@ -0,0 +1,81 @@
package pw.binom.agentik.memory
/**
* Пара (пользователь, ассистент) для review-loop'а.
*
* @property conversationId id диалога, из которого взят ход. Нужен, чтобы
* потом привязать появившиеся заметки к диалогу
* (глобальные заметки идут с conversationId=null).
*/
data class ReviewedTurn(
val userMessage: String,
val assistantMessage: String,
val conversationId: String? = null,
)
/**
* Пара (user + assistant) с временной меткой для пакетного review-loop'а.
* Используется при compaction'е working memory — когда ходы уходят в summary,
* у нас последний шанс вытащить из них факты и положить в долговременную память.
*/
data class ConversationTurn(
val userMessage: String,
val assistantMessage: String,
val createdAt: kotlin.time.Instant? = null,
)
/**
* Кандидат на новую заметку, предложенный review-loop'ом. У `id` нет —
* бэкенд назначает при upsert.
*/
data class NewMemoryNote(
val category: MemoryCategory,
val content: String,
)
/**
* Обновление существующей заметки (например, исправление формулировки).
*/
data class MemoryUpdate(
val id: String,
val newContent: String? = null,
)
/**
* Вердикт review-loop'а по одному ходу: что сохранить, что обновить, что удалить.
*/
data class MemoryReviewDecision(
val toSave: List<NewMemoryNote> = emptyList(),
val toUpdate: List<MemoryUpdate> = emptyList(),
val toDelete: List<String> = emptyList(),
)
/**
* Анализирует завершённый ход и возвращает вердикт — что должно попасть в
* долговременную память (или наоборот — удалиться).
*
* Реализации:
* - `:memory-md` — простая эвристика по ключевым словам (user/preference markers).
* - `:standalone` (позже) — один-shot LLM-вызов с whitelist-тулсетом.
*/
interface MemoryReviewer {
/** Review одного завершённого хода (вызывается после каждого assistant-ответа). */
suspend fun review(turn: ReviewedTurn): MemoryReviewDecision
/**
* Review пачки ходов перед compaction'ом working memory. Зовётся агентом
* за один раз перед удалением старых ходов — последний шанс вытащить из них
* факты до того, как они схлопнутся в summary.
*
* Дефолтная реализация — наивная: скармливает каждый ход в [review] по
* отдельности. Реализации с настоящей LLM-семантикой могут посмотреть на
* ходы пакетом и принимать решения с учётом контекста (например, не дублировать
* уже сохранённые факты).
*/
suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
val aggregated = MemoryReviewDecision(
toSave = turns.flatMap { review(ReviewedTurn(userMessage = it.userMessage, assistantMessage = it.assistantMessage)).toSave },
)
return aggregated
}
}
@@ -0,0 +1,28 @@
package pw.binom.agentik.memory
/**
* Запрос на семантический (или, в MD-бэкенде — ключевой) поиск по памяти.
*
* @property query текст запроса (обычно — последнее сообщение пользователя).
* @property topK максимум возвращаемых результатов.
* @property category фильтр по категории или null для всех.
* @property conversationId фильтр по диалогу: null = глобальная память,
* конкретный id = только факты этого диалога,
* особое значение [""] НЕ поддерживается — нужен явный диалог
* или null.
*/
data class MemorySearchQuery(
val query: String,
val topK: Int = 10,
val category: MemoryCategory? = null,
val conversationId: String? = null,
)
/**
* Результат поиска с оценкой релевантности. Шкала `score` бэкенд-специфична
* (для MD — overlap/total; для векторного — косинусная близость). Семантика — больше = лучше.
*/
data class MemorySearchResult(
val note: MemoryNote,
val score: Float,
)
@@ -0,0 +1,20 @@
package pw.binom.agentik.memory
/**
* Канал, через который заметка попала в память.
*
* - [AGENT_SAVE] — агент сам решил сохранить факт (явный вызов `memory_save` тулом).
* - [USER_EXPLICIT] — пользователь попросил сохранить факт.
* - [AUTO_REVIEW] — фоновый review-loop после хода (см. `MemoryReviewer`).
*/
enum class MemorySource(val id: String) {
AGENT_SAVE("agent_save"),
USER_EXPLICIT("user_explicit"),
AUTO_REVIEW("auto_review");
companion object {
fun fromId(id: String): MemorySource =
entries.firstOrNull { it.id == id }
?: throw IllegalArgumentException("unknown memory source: $id")
}
}
@@ -0,0 +1,46 @@
package pw.binom.agentik.memory
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/**
* Событие мутации памяти для подписчиков (используется review-loop'ом и UI).
*/
sealed interface MemoryStoreEvent {
data class Upserted(val note: MemoryNote) : MemoryStoreEvent
data class Deleted(val id: String) : MemoryStoreEvent
}
/**
* Бэкенд-независимое хранилище долговременной памяти агента.
*
* Контракт:
* - [upsert] заменяет запись по `id` либо добавляет новую.
* - [get] / [list] / [search] — синхронные по id, листаются с пагинацией, поиск скорируется бэкендом.
* - [delete] удаляет по id; возвращает true если запись была.
* - [markUsed] бампит `lastUsedAt` и `useCount` — вызывается на каждом выдавании в prefetch.
* - [close] идемпотентен; после него любые методы бросают.
* - [events] опциональный стрим мутаций; бэкенды без поддержки возвращают [emptyFlow].
*
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных вызовов.
*/
interface MemoryStore : AutoCloseable {
suspend fun upsert(note: MemoryNote)
suspend fun get(id: String): MemoryNote?
suspend fun list(
category: MemoryCategory? = null,
conversationId: String? = null,
limit: Int = 100,
offset: Int = 0,
): List<MemoryNote>
suspend fun search(query: MemorySearchQuery): List<MemorySearchResult>
suspend fun delete(id: String): Boolean
suspend fun markUsed(id: String, at: Instant = Clock.System.now())
fun events(): Flow<MemoryStoreEvent> = emptyFlow()
override fun close()
}
@@ -0,0 +1,12 @@
package pw.binom.agentik.memory
/**
* Бандл компонентов памяти (store + prefetcher + reviewer), общий интерфейс
* для всех бэкендов (`:memory-md`, `:memory-vector`, ...). Используется в
* `:standalone` для единообразного DI.
*/
interface MemorySystem : AutoCloseable {
val store: MemoryStore
val prefetcher: MemoryPrefetcher
val reviewer: MemoryReviewer
}
@@ -0,0 +1,37 @@
package pw.binom.agentik.memory
/**
* Готовые блоки system-guidance, которые `:standalone` подмешивает в
* system-prompt разговора и в review-промпт. Тексты согласованы с
* `docs/MEMORY-DESIGN.md` (§6, §10) и описаниями [DefaultMemoryTools].
*/
object MemorySystemGuidance {
/** Блок для основного system-prompt разговора. Объясняет агенту, что у него есть память. */
const val MEMORY_GUIDANCE: String = """
У тебя есть долговременная память. Доступны тулы:
- memory_save(category, content) — сохранить факт, который пригодится в будущем.
- memory_read(query, top_k?) — поиск по памяти, когда нужен контекст.
- memory_list(category?, limit?) — список фактов (например, для показа пользователю).
- memory_delete(id) — удалить факт, когда пользователь просит забыть.
Категории:
- user: о пользователе (кто он, чем занимается, привычки).
- world: о проектах, стеке, окружении, людях.
- preference: как пользователь хочет, чтобы ты работал.
НЕ сохраняй: секреты (API-ключи, токены, пароли), одноразовые факты,
догадки без подтверждения. Сомневаешься — не сохраняй.
"""
/** Промпт для review-loop'а (см. `MemoryReviewer`). */
const val REVIEW_GUIDANCE: String = """
Ты — фоновый аналитик. Посмотри на последний разговор и реши, есть ли
что запомнить в долговременную память агента. Сохраняй только если:
1. Пользователь рассказал о себе: persona, привычки, предпочтения.
2. Пользователь рассказал о проекте/окружении: стек, инструменты, сроки.
3. Пользователь выразил ожидания к тому, как агент должен работать.
Если ничего нет — просто ответь "nothing to save" и не вызывай тулы.
Если есть — вызови memory_save(category, content) для каждого факта.
"""
}
@@ -0,0 +1,105 @@
package pw.binom.agentik.memory
/**
* Описание одного параметра инструмента памяти. Платформо-агностично —
* :standalone оборачивает это в `LiteTool` или backend-специфичные сущности.
*/
data class MemoryToolParam(
val name: String,
val type: String,
val description: String,
val required: Boolean = true,
val enumValues: List<String>? = null,
)
/**
* Описание инструмента, который видит LLM/агент. Имя/описание/параметры —
* то, что попадёт в system-prompt или tool-call schema.
*/
data class MemoryToolDescriptor(
val name: String,
val description: String,
val params: List<MemoryToolParam> = emptyList(),
)
/**
* Набор инструментов, которые память предоставляет агенту. Конкретный движок
* (LiteRT-LM, A2A, IRC) оборачивает эти дескрипторы в свои tool-классы.
*/
interface MemoryTools {
val save: MemoryToolDescriptor
val read: MemoryToolDescriptor
val list: MemoryToolDescriptor
val delete: MemoryToolDescriptor
companion object {
fun defaults(): MemoryTools = DefaultMemoryTools
}
}
/**
* Дефолтные описания инструментов. Язык — русский, чтобы согласовываться с
* [MemorySystemGuidance.MEMORY_GUIDANCE].
*/
object DefaultMemoryTools : MemoryTools {
override val save: MemoryToolDescriptor = MemoryToolDescriptor(
name = "memory_save",
description = "Сохранить факт в долговременную память агента. Категория — одна из " +
"'user' (о пользователе: persona, привычки, предпочтения), " +
"'world' (о проектах, стеке, окружении, инструментах), " +
"'preference' (как пользователь хочет, чтобы ты работал). " +
"НЕ сохраняй секреты (API-ключи, токены, пароли) — pattern-detect и отказывай.",
params = listOf(
MemoryToolParam(
name = "category",
type = "string",
description = "Категория факта.",
required = true,
enumValues = listOf("user", "world", "preference"),
),
MemoryToolParam(
name = "content",
type = "string",
description = "Полный текст факта одним-двумя предложениями.",
required = true,
),
),
)
override val read: MemoryToolDescriptor = MemoryToolDescriptor(
name = "memory_read",
description = "Поиск по долговременной памяти. Возвращает до top_k заметок, " +
"упорядоченных по релевантности (наибольшая первой). " +
"Используй перед ответами, требующими контекста о пользователе/проекте.",
params = listOf(
MemoryToolParam("query", "string", "Поисковый запрос (подстрока или ключевые слова).", true),
MemoryToolParam("top_k", "number", "Максимум заметок в ответе (default 10).", false),
MemoryToolParam(
"category", "string", "Фильтр по категории.", false,
enumValues = listOf("user", "world", "preference"),
),
),
)
override val list: MemoryToolDescriptor = MemoryToolDescriptor(
name = "memory_list",
description = "Показать все (или отфильтрованные) заметки памяти. " +
"Используй, когда пользователь хочет проверить, что агент помнит.",
params = listOf(
MemoryToolParam(
"category", "string", "Фильтр по категории.", false,
enumValues = listOf("user", "world", "preference"),
),
MemoryToolParam("limit", "number", "Сколько заметок вернуть (default 100).", false),
),
)
override val delete: MemoryToolDescriptor = MemoryToolDescriptor(
name = "memory_delete",
description = "Удалить факт из памяти по id. Используй, когда пользователь явно " +
"просит забыть что-то.",
params = listOf(
MemoryToolParam("id", "string", "id заметки (формат mem-<uuid>).", true),
),
)
}