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:
@@ -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" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user