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
+2
View File
@@ -21,6 +21,8 @@ kotlin {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
// для JsonElement в MessageContext.metadata
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
@@ -37,8 +37,14 @@ interface Conversation : AutoCloseable {
*
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
*
* @param content тело сообщения (текст/картинки).
* @param context опциональный контекст инициации хода: кто/что и почему.
* `null` = обычное user-сообщение. Используется для cron/webhook/system
* событий — модель увидит в working memory префикс
* `[origin] description (sourceId=…)` к тексту сообщения.
*/
suspend fun send(content: List<Content>)
suspend fun send(content: List<Content>, context: MessageContext? = null)
/**
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
@@ -20,7 +20,16 @@ sealed interface Message {
@Serializable
@SerialName("user_message")
class UserMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
class UserMessage(
override val id: String,
val content: List<Content>,
override val date: Instant,
/**
* Контекст инициации хода: кто/что вызвало этот turn. `null` —
* обычное user-сообщение. См. [MessageContext].
*/
val context: MessageContext? = null,
) : Message
@Serializable
@SerialName("assistant_message")
@@ -0,0 +1,94 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Кто/что инициировал данный ход сообщения.
*
* Используется в [MessageContext] — каждый ход диалога может нести
* дополнительный контекст о природе триггера:
* - [USER] — обычное сообщение от пользователя в чате (дефолт, context=null).
* - [SYSTEM] — программное системное сообщение (старт агента, режим обслуживания,
* уведомление о завершении фоновой задачи).
* - [EVENT] — внешнее событие (cron, webhook, file-changed, и т.п.).
* В этом случае [MessageContext.sourceId] и [MessageContext.description]
* позволяют модели понять, что за источник её разбудил.
*
* Семантический контракт:
* - origin != USER ⇒ [MessageContext.description] обязателен и должен быть
* человекочитаемым (короткая фраза для модели).
* - origin == USER ⇒ context может быть `null` (дефолт), и если задан — поля
* интерпретируются как «дополнительная мета» (например, ui_client).
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
/**
* Контекст инициации сообщения: кто/что и почему вызвало этот ход.
*
* Примеры:
* ```
* // cron-задача утренней сводки
* MessageContext(
* origin = MessageOrigin.EVENT,
* description = "scheduled cron 'morning-briefing'",
* sourceId = "cron-42",
* metadata = buildJsonObject { put("scheduledAt", "2026-09-14T08:00:00Z") },
* )
*
* // обычное сообщение из IRC
* MessageContext(
* origin = MessageOrigin.USER,
* sourceId = "irc-channel:agentik",
* description = "PRIVMSG from nick",
* )
*
* // старт агента после рестарта
* MessageContext(
* origin = MessageOrigin.SYSTEM,
* description = "agent startup greeting",
* )
* ```
*
* Сериализация: snake_case для стабильного wire-формата ([origin] идёт как
* `user`/`system`/`event` благодаря @SerialName на enum).
*
* Forward-совместимо: добавление новых полей — non-breaking для старых
* клиентов, которые их игнорируют.
*/
@Serializable
data class MessageContext(
val origin: MessageOrigin,
/**
* Короткая человекочитаемая фраза для LLM: попадает в working memory
* как префикс `[origin] description (sourceId=…)` к user-сообщению,
* чтобы модель видела, что её разбудил не пользователь, а событие.
*/
val description: String? = null,
/**
* Идентификатор источника: id cron-job'а, webhook endpoint'а, имя канала IRC,
* id фонового события. Помогает модели и оператору при логировании понять,
* откуда пришёл ход.
*/
val sourceId: String? = null,
/**
* Произвольный структурированный payload о событии.
* Например: `{"scheduledAt": "...", "rule": "..."}` для cron,
* или `{"headers": {...}, "ip": "..."}` для webhook.
*
* Никогда не попадает в LLM-нагрузку как сырой JSON — используется
* только для логирования и пост-аналитики.
*/
val metadata: JsonElement? = null,
)
@@ -0,0 +1,88 @@
package pw.binom.agentik.proto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Тесты сериализации MessageOrigin/MessageContext.
*
* Гарантируем:
* 1) enum origin → snake_case discriminator (`user`/`system`/`event`);
* 2) MessageContext → стабильный JSON-формат с полями `origin`, `description`,
* `sourceId`, `metadata`;
* 3) парсинг round-trip без потерь.
*/
class MessageContextTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `USER origin serializes as user`() {
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.USER)
assertEquals("\"user\"", s)
}
@Test
fun `SYSTEM origin serializes as system`() {
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.SYSTEM)
assertEquals("\"system\"", s)
}
@Test
fun `EVENT origin serializes as event`() {
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.EVENT)
assertEquals("\"event\"", s)
}
@Test
fun `origin deserializes from snake_case`() {
assertEquals(MessageOrigin.USER, json.decodeFromString(MessageOrigin.serializer(), "\"user\""))
assertEquals(MessageOrigin.SYSTEM, json.decodeFromString(MessageOrigin.serializer(), "\"system\""))
assertEquals(MessageOrigin.EVENT, json.decodeFromString(MessageOrigin.serializer(), "\"event\""))
}
@Test
fun `USER context with no fields roundtrips`() {
val ctx = MessageContext(origin = MessageOrigin.USER)
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
// description/sourceId/metadata отсутствуют → не должны попасть в JSON (explicitNulls=false + defaults)
assertEquals("""{"origin":"user"}""", encoded)
val decoded = json.decodeFromString(MessageContext.serializer(), encoded)
assertEquals(ctx, decoded)
assertNull(decoded.description)
assertNull(decoded.sourceId)
assertNull(decoded.metadata)
}
@Test
fun `EVENT context with all fields roundtrips`() {
val ctx = MessageContext(
origin = MessageOrigin.EVENT,
description = "scheduled cron morning-briefing",
sourceId = "cron-42",
metadata = buildJsonObject {
put("scheduledAt", JsonPrimitive("2026-09-14T08:00:00Z"))
put("rule", JsonPrimitive("0 8 * * *"))
},
)
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
val decoded = json.decodeFromString(MessageContext.serializer(), encoded)
assertEquals(ctx, decoded)
assertEquals(MessageOrigin.EVENT, decoded.origin)
assertEquals("scheduled cron morning-briefing", decoded.description)
assertEquals("cron-42", decoded.sourceId)
assertEquals(ctx.metadata, decoded.metadata)
}
@Test
fun `origin field name is origin in JSON`() {
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "boot")
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
// Поле должно называться ровно `origin` — клиенты могут на него полагаться.
assert(encoded.contains("\"origin\":\"system\"")) { "expected origin field, got: $encoded" }
}
}