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
@@ -1,7 +1,9 @@
package pw.binom.agentik.server
import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant
/**
@@ -32,3 +34,19 @@ internal data class RequestCreateConversation(val temp: Boolean)
@Serializable
internal data class RequestRename(val title: String)
/**
* Тело `POST /conversations/{id}/messages`.
*
* Поддерживает два формата для backward-compat:
* - **новый**: `{"content": [...], "context": {...}}` — с контекстом инициации;
* - **старый**: голый JSON-массив `[...]` контента — context=null (обычный user).
*
* Маршрутизация формата делается в обработчике через try-{catch}-fallback на
* `List<Content>` десериализацию.
*/
@Serializable
internal data class RequestSendMessage(
val content: List<Content>,
val context: MessageContext? = null,
)
@@ -5,6 +5,7 @@ import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytesWriter
import io.ktor.server.response.respondText
@@ -78,8 +79,17 @@ internal fun Route.agentikRoutes(agent: Agent) {
call.respond(HttpStatusCode.NotFound)
return@post
}
val content = call.receive<List<Content>>()
c.send(content)
// Backward-compat: принимаем либо голый массив Content (старые клиенты),
// либо объект {content, context}. Парсим как RequestSendMessage первой —
// она устойчива к пустому/null context, и только если тип payload'а не
// объект — fallback на List<Content>.
val raw = call.receiveText()
val parsed = parseSendRequest(raw)
if (parsed == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid send payload (expected array of Content or {content, context?})")
return@post
}
c.send(parsed.content, parsed.context)
call.respond(HttpStatusCode.Accepted)
}
@@ -159,3 +169,28 @@ private suspend fun <T> ApplicationCall.streamJsonSse(
}
}
}
/**
* Парсит тело `POST /conversations/{id}/messages` в двух форматах:
* 1) новый объект `{content: [...], context?: {...}}`;
* 2) старый голый массив `[...]` (context = null).
*
* Возвращает null при невалидном JSON в обоих форматах.
*/
private fun parseSendRequest(raw: String): RequestSendMessage? {
val trimmed = raw.trimStart()
if (trimmed.startsWith("[")) {
// старый формат
return runCatching {
val list = agentikJson.decodeFromString(
kotlinx.serialization.builtins.ListSerializer(Content.serializer()),
raw,
)
RequestSendMessage(content = list, context = null)
}.getOrNull()
}
// новый формат (или что-то иное — пробуем распарсить объект)
return runCatching {
agentikJson.decodeFromString(RequestSendMessage.serializer(), raw)
}.getOrNull()
}