feat(memory): migrate EmbeddingProvider to KMP-compatible TextEmbeddingExecutor, add :memory-md-vector, and hybrid backend support
ci / JVM build + tests (push) Failing after 11s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 10s

- Replaced `EmbeddingProvider` with cross-platform `TextEmbeddingExecutor` for native target compatibility.
- Introduced `:memory-md-vector` module combining vector-cache and `.md` file-based memory systems (`hybrid` backend).
- Updated `SiglipEmbeddingProvider` to use KMP `TextEmbeddingExtractor` and streamlined compatibility via `asExecutor`.
- Added hybrid memory backend to `standalone`, supporting `.md` reconciliation with vector-cache for semantic
This commit is contained in:
2026-09-21 12:28:09 +03:00
parent f946186ef5
commit 68543357c2
19 changed files with 658 additions and 165 deletions
+42
View File
@@ -57,6 +57,9 @@ kotlin {
implementation(project(":memory-md"))
if (!skipVectorMemory) {
implementation(project(":memory-vector"))
// Гибрид: .md (single source of truth) + ksqlite vector-кэш
// (sqlite-vec vec0). Подключается при AGENTIK_MEMORY_BACKEND=hybrid.
implementation(project(":memory-md-vector"))
}
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
@@ -166,3 +169,42 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
includeEmptyDirs = false
}
// :standalone deploy
//
// `deployJar` собирает fatjar через shadowJar и копирует его на приватный
// домашний хост (root@192.168.76.166). Репа приватная, IP захардкожен —
// пользователь явно сказал "можно в наглую указать конкретно этот ip".
// Если понадобится другой хост — переопредели через -PagentikDeployHost=...
//
// Конвенция имени файла: agentik-standalone-{version}-all.jar (как на хосте
// уже лежат agentik-cli-0.1.0-all.jar / agentik-0.1.0-all.jar —
// выровнено по стилю существующего деплоя).
//
// SSH — ключевой, пароль не спрашивает. Если упадёт — проверь
// `ssh root@192.168.76.166 'echo OK'` вручную.
val agentikDeployHost: String =
(project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.166"
val agentikDeployPath: String =
(project.findProperty("agentikDeployPath") as? String) ?: "~/agentik-standalone-${project.version}-all.jar"
tasks.register("deployJar") {
group = "deployment"
description = "Builds shadowJar and copies it to $agentikDeployHost:$agentikDeployPath"
dependsOn(shadowJarTask)
doLast {
val jar = shadowJarTask.get().outputs.files.singleFile
val target = "$agentikDeployHost:$agentikDeployPath"
println("→ scp $jar $target")
val proc = ProcessBuilder("scp", jar.absolutePath, target)
.redirectErrorStream(true)
.start()
proc.inputStream.bufferedReader().forEachLine { println("scp> $it") }
val rc = proc.waitFor()
if (rc != 0) {
throw GradleException("scp failed (exit=$rc). Check ssh access to $agentikDeployHost.")
}
println("✓ deployed to $target")
}
}
@@ -15,6 +15,7 @@ import pw.binom.a2a.server.a2aAgent
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.md.openMdMemorySystem
import pw.binom.agentik.memory.mdvector.openHybridMemorySystem
import pw.binom.agentik.memory.vector.VectorMemorySystem
import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient
import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
@@ -256,7 +257,7 @@ private fun runServer() {
}
}
MemoryBackend.VECTOR -> {
val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embedding.backend) {
val embedding: pw.binom.agentik.memory.TextEmbeddingExecutor = when (config.embedding.backend) {
AppConfig.EmbeddingBackend.HTTP -> {
val llm = config.llm
// Берём базовый URL + API key у активного LLM-бэкенда.
@@ -271,7 +272,7 @@ private fun runServer() {
apiKey = oa.apiKey,
model = config.embedding.model,
dimension = config.embedding.dimension,
)
).asExecutor()
}
AppConfig.EmbeddingBackend.SIGLIP -> {
val modelPath = checkNotNull(config.embedding.modelPath) {
@@ -280,7 +281,7 @@ private fun runServer() {
val tokenizerPath = checkNotNull(config.embedding.tokenizerPath) {
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_TOKENIZER_PATH"
}
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath)
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath).asExecutor()
}
}
VectorMemorySystem.open(
@@ -296,6 +297,44 @@ private fun runServer() {
println(" memory: db=${config.agent.dbPath} (vector-backend, $backendLabel)")
}
}
MemoryBackend.HYBRID -> {
// Гибрид: .md файлы (single source of truth) + ksqlite vector-кэш.
// Нужны embeddings (HTTP/SIGLIP) + путь к vector-БД (по умолчанию рядом с agentik.db).
val embedding: pw.binom.agentik.memory.TextEmbeddingExecutor = when (config.embedding.backend) {
AppConfig.EmbeddingBackend.HTTP -> {
val llm = config.llm
require(llm.backend == LlmBackend.OPENAI) {
"AGENTIK_EMBEDDING_BACKEND=http требует LLM_BACKEND=openai"
}
val oa = checkNotNull(llm.openai) { "openai config required" }
HttpEmbeddingClient(
apiUrl = oa.baseUrl.trimEnd('/'),
apiKey = oa.apiKey,
model = config.embedding.model,
dimension = config.embedding.dimension,
).asExecutor()
}
AppConfig.EmbeddingBackend.SIGLIP -> {
val modelPath = checkNotNull(config.embedding.modelPath) {
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH"
}
val tokenizerPath = checkNotNull(config.embedding.tokenizerPath) {
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_TOKENIZER_PATH"
}
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath).asExecutor()
}
}
val mdDir = rawMemory.takeUnless { it.equals("off", true) } ?: defaultMemoryDir()
val vectorDbPath = deriveVectorDbPath(config.agent.dbPath)
openHybridMemorySystem(
memoryRoot = Path(mdDir),
vectorDbPath = Path(vectorDbPath),
dimension = embedding.dimension,
embedder = embedding,
).also {
println(" memory: md=$mdDir, vector-cache=$vectorDbPath (hybrid-backend, dim=${embedding.dimension})")
}
}
}
// Контекстное окно модели (для compaction'а working memory).
@@ -428,3 +467,12 @@ private fun defaultMemoryDir(): String {
val home = System.getProperty("user.home") ?: "."
return "$home/.agentik/memory"
}
/**
* Производный путь к vector-БД гибридного бэкенда: рядом с `agentik.db`,
* но с суффиксом `-vectors`. Например: `agentik.db` → `agentik-vectors.db`.
*/
private fun deriveVectorDbPath(agentDbPath: String): String {
val withoutExt = agentDbPath.removeSuffix(".db")
return "$withoutExt-vectors.db"
}
@@ -115,9 +115,29 @@ data class AppConfig(
val endpoints: Boolean = false,
)
/** Бэкенд долговременной памяти. */
/** Бэкенд долговременной памяти.
*
* Env-алиасы (для удобства CLI):
* - `md` → [MD] (Hermes-style §-файлы)
* - `vector` → [VECTOR] (sqlite + JVector + LLM-embeddings)
* - `md-vector` → [HYBRID] (md-файлы = source of truth + sqlite vector-кэш)
* - `off` → [OFF] (память выключена)
*/
@Serializable
enum class MemoryBackend { MD, VECTOR, OFF }
enum class MemoryBackend {
MD, VECTOR, HYBRID, OFF;
companion object {
/** Маппинг env-строки (lowercase, как приходит из shell) → enum. */
fun fromEnvString(s: String): MemoryBackend? = when (s.lowercase()) {
"md" -> MD
"vector" -> VECTOR
"md-vector", "hybrid" -> HYBRID
"off", "none" -> OFF
else -> null
}
}
}
/** Бэкенд эмбеддингов (для memory-backend=vector). */
@Serializable
@@ -183,7 +203,7 @@ data class AppConfig(
memory = MemorySection(
dir = env("AGENTIK_MEMORY_DIR")?.takeIf { it.isNotBlank() },
backend = env("AGENTIK_MEMORY_BACKEND")?.let {
runCatching { MemoryBackend.valueOf(it.uppercase()) }.getOrNull()
MemoryBackend.fromEnvString(it)
} ?: MemoryBackend.MD,
compressionThreshold = env("AGENTIK_COMPRESSION_THRESHOLD")?.toDoubleOrNull()
?.coerceIn(0.1, 0.99) ?: DEFAULT_COMPRESSION_THRESHOLD,